diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts index db5315e6..42863231 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts @@ -30,16 +30,28 @@ export interface DesignDrawingListResponse { /** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */ export type QuantityTable = Record; +/** 측구 형식별 규격 (B06 엔진 ditch_spec 신구조와 1:1). */ +export type DitchSpec = + | { type: "none" } + | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } + | { type: "l_type"; width_m: number; depth_m: number }; + /** B06에서 지정한 설계(지반정보·계획정보). 횡단도에만 존재. status로 잠정/확정 구분. */ export interface CrossDesignInfo { ground_type: "soil" | "ripping_rock" | "blasting_rock"; geometry_preset: "soil" | "rock"; section_mode: "left_cut" | "right_cut" | "both_cut" | "both_fill"; ditch_side: "left" | "right"; + ditch_type?: "standard" | "l_type" | null; + ditch_enabled?: boolean; cut_slope_ratio: number; fill_slope_ratio: number; roadbed_width_m: number; - ditch: { width_m: number; depth_m: number }; + carriageway_width_m?: number; + cross_slope_pct?: number; + paved?: boolean; + ditch: DitchSpec; + road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad.py new file mode 100644 index 00000000..77fcd1a2 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad.py @@ -0,0 +1,691 @@ +"""B07 CAD 도면 조립 엔진 — B06/B05 산출물을 openwebcad 엔티티로 직렬화한다. + +납품 도면 양식 복제: + - 종단도: 30측점 N분할 + 하단 측점 테이블(곡선/측점/거리/추가거리/지반고/ + 계획고/절토고/성토고/구배). 곡선·구배 행은 B05 profile_alignment의 + 종단곡선(curves)·구배(segments) 기하를 그대로 표기한다. + - 횡단도: 지표/설계/구조물 레이어 분리 + 하단 수량 산출표(No.행, 지반고~ + 절토고 헤더행, 깍기·측구·쌓기·층따기 / 면고르기·지장목제거·표토제거 / + 편책·성토파종·절토살포·제근·노면다짐 3그룹 병합 셀 그리드). + +모든 값 텍스트는 잠금 해제 레이어의 Text 엔티티라 CAD에서 직접 수정할 수 +있고, id가 결정적(uuid5)이라 확정 시 도면 JSON에서 수량표 값을 역추출한다. + +좌표 규약: 종단도 x=chainage_m, 횡단도 x=offset_m(+좌/-우), y=elevation_m. +""" + +from typing import Any +from uuid import UUID, uuid5 + +# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3). +GROUND_LAYER_ID = "b07-ground" +GROUND_COLOR = "#f5f7fa" +DESIGN_LAYER_ID = "b07-design" +DESIGN_COLOR = "#b794f6" +STRUCTURE_LAYER_ID = "b07-structure" +STRUCTURE_COLOR = "#f6d55c" +ROCK_LAYER_ID = "b07-rock-boundary" +ROCK_COLOR = "#f59e0b" +FRAME_LAYER_ID = "b07-frame" +LONG_TABLE_LAYER_ID = "b07-long-table" +CROSS_TABLE_LAYER_ID = "b07-cross-table" +TABLE_LINE_COLOR = "#8ea0b5" +TABLE_LABEL_COLOR = "#e8edf4" +TABLE_VALUE_COLOR = "#ffe066" + +# 종단도 분할 기준: 측점 30개 초과 시 30개 단위(경계 1측점 중복)로 나눈다. +LONG_SPLIT_STATION_COUNT = 30 + +_ENTITY_NS = UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96") +_POLY_NS = UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8") + +# 도면 직렬화 포맷 버전. 테이블·레이어 구성 변경 시 올린다 — 확정 저장본이 +# 이 버전과 다르면 캐시를 버리고 원본에서 재생성한다(구양식 서빙 방지). +# v3: 횡단 로컬좌표(계획고=0) 정규화 + 공통 프레임 + 암 경계선 레이어. +# v4: 종단 그래프 축·회색 측점 세로선·기준선(datum) + 테이블 눈금·세로쓰기·구배 원 표기. +# v5: 횡단 콘텐츠 bbox 중심 정렬(경사 드리프트 제거) + 외곽 테두리 제거. +# v6: 외곽 테두리 복구 (콘텐츠 중심 정렬 유지, 노선 공통 크기 사각형). +DRAWING_FORMAT = 6 + + +# --------------------------------------------------------------------------- +# 엔티티 직렬화 헬퍼 +# --------------------------------------------------------------------------- +def _line_entity( + seed: str, + start: tuple[float, float], + end: tuple[float, float], + layer_id: str, + color: str, +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Line", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "startPoint": {"x": start[0], "y": start[1]}, + "endPoint": {"x": end[0], "y": end[1]}, + }, + } + + +def polyline_entity( + drawing_id: str, + points: list[tuple[float, float]], + layer_id: str, + color: str, + suffix: str = "", + dash: list[int] | None = None, +) -> dict[str, Any] | None: + """점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None).""" + if len(points) < 2: + return None + seed_base = f"{drawing_id}:{layer_id}{suffix}" + children = [] + for index in range(len(points) - 1): + child = _line_entity( + f"{seed_base}:{index}", points[index], points[index + 1], layer_id, color + ) + if dash: + child["lineDash"] = dash + children.append(child) + poly: dict[str, Any] = { + "id": str(uuid5(_POLY_NS, seed_base)), + "type": "PolyLine", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": None, + "children": children, + } + if dash: + poly["lineDash"] = dash + return poly + + +def _text_entity( + seed: str, + label: str, + x: float, + y: float, + layer_id: str, + font_size: float, + color: str, + align: str = "center", + direction: tuple[float, float] = (1.0, 0.0), +) -> dict[str, Any]: + """direction=(0,1)이면 세로쓰기(아래→위) — 납품 종단 테이블 값 표기.""" + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Text", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "label": label, + "basePoint": {"x": x, "y": y}, + "options": { + "textDirection": {"x": direction[0], "y": direction[1]}, + "textAlign": align, + "textColor": color, + "fontSize": font_size, + "fontFamily": "sans-serif", + }, + }, + } + + +def _layer(layer_id: str, name: str, locked: bool = False) -> dict[str, Any]: + return {"id": layer_id, "name": name, "isVisible": True, "isLocked": locked} + + +# --------------------------------------------------------------------------- +# 공용 데이터 헬퍼 +# --------------------------------------------------------------------------- +def points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]: + """유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m).""" + points: list[tuple[float, float]] = [] + for sample in samples: + if not isinstance(sample, dict) or not sample.get("valid", False): + continue + x = sample.get(x_key) + y = sample.get("elevation_m", sample.get("z")) + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + return points + + +def _design_profile_points(longitudinal: dict[str, Any]) -> list[tuple[float, float]]: + profiles = longitudinal.get("design_profiles") + if not isinstance(profiles, list) or not profiles: + return [] + points: list[tuple[float, float]] = [] + for point in profiles[0].get("samples", []): + if not isinstance(point, dict): + continue + x = point.get("chainage_m") + y = point.get("elevation_m") + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + return points + + +def _interpolate(points: list[tuple[float, float]], x: float) -> float | None: + """정렬 점열의 선형 보간(범위 밖 끝값 클램프). 점이 없으면 None.""" + if not points: + return None + if x <= points[0][0]: + return points[0][1] + if x >= points[-1][0]: + return points[-1][1] + for index in range(1, len(points)): + x1, y1 = points[index] + if x > x1: + continue + x0, y0 = points[index - 1] + span = x1 - x0 + if span <= 0: + return y1 + return y0 + (y1 - y0) * (x - x0) / span + return points[-1][1] + + +def _stations(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: + stations = longitudinal.get("stations") + if not isinstance(stations, list): + return [] + return [ + station + for station in stations + if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) + ] + + +def infer_station_interval(stations: list[dict[str, Any]]) -> float: + """연속 chainage 차이의 최빈값으로 측점 간격을 추정한다 (No. 표기·칸 폭 기준).""" + counts: dict[float, int] = {} + chainages = sorted( + float(s["chainage_m"]) + for s in stations + if isinstance(s, dict) and isinstance(s.get("chainage_m"), (int, float)) + ) + for index in range(1, len(chainages)): + difference = round(chainages[index] - chainages[index - 1], 1) + if difference > 0: + counts[difference] = counts.get(difference, 0) + 1 + if not counts: + return 20.0 + return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0] + + +def station_no_label(chainage_m: float, interval_m: float) -> str: + """납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리).""" + safe = interval_m if interval_m > 0 else 1.0 + number = int((chainage_m + 1e-6) // safe) + remainder = chainage_m - number * safe + if remainder >= safe - 0.05: + number += 1 + remainder = 0.0 + if abs(remainder) < 0.05: + return f"No.{number}" + return f"No.{number}+{remainder:.1f}" + + +def _profile_alignment(longitudinal: dict[str, Any]) -> dict[str, Any]: + alignment = longitudinal.get("profile_alignment") + return alignment if isinstance(alignment, dict) else {} + + +def _format(value: float | None, decimals: int = 2) -> str: + return "" if value is None else f"{value:.{decimals}f}" + + +# --------------------------------------------------------------------------- +# 횡단도: 레이어 분리 + 수량 산출표 (납품 양식) +# --------------------------------------------------------------------------- +def _ditch_bounds(design: dict[str, Any] | None) -> tuple[float, float] | None: + """설계 dict에서 측구(구조물) 오프셋 구간 [lo, hi]를 구한다. 없으면 None.""" + if not isinstance(design, dict) or not design.get("ditch_enabled"): + return None + ditch = design.get("ditch") + edges = design.get("road_edges") + side = design.get("ditch_side") + if not isinstance(ditch, dict) or not isinstance(edges, dict) or side not in ("left", "right"): + return None + ditch_type = ditch.get("type") + if ditch_type == "standard": + width = ditch.get("top_width_m") + elif ditch_type == "l_type": + width = ditch.get("width_m") + else: + return None + edge = edges.get(side) + if not isinstance(edge, dict) or not isinstance(width, (int, float)): + return None + inner = edge.get("offset_m") + if not isinstance(inner, (int, float)): + return None + outer = float(inner) + (float(width) if side == "left" else -float(width)) + return (min(float(inner), outer), max(float(inner), outer)) + + +def _cross_line_entities( + drawing_id: str, + design_line: list[Any] | None, + design: dict[str, Any] | None, + dy: float = 0.0, +) -> list[dict[str, Any]]: + """횡단 설계선을 설계(b07-design)/구조물(b07-structure, 측구 구간)로 분리한다.""" + points: list[tuple[float, float]] = [] + for point in design_line if isinstance(design_line, list) else []: + if not isinstance(point, dict): + continue + x = point.get("offset_m") + y = point.get("elevation_m") + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y) - dy)) + if len(points) < 2: + return [] + + bounds = _ditch_bounds(design) + entities: list[dict[str, Any]] = [] + if bounds is None: + design_poly = polyline_entity(drawing_id, points, DESIGN_LAYER_ID, DESIGN_COLOR) + return [design_poly] if design_poly else [] + + lo, hi = bounds + tolerance = 1e-6 + before = [p for p in points if p[0] <= lo + tolerance] + ditch_part = [p for p in points if lo - tolerance <= p[0] <= hi + tolerance] + after = [p for p in points if p[0] >= hi - tolerance] + for suffix, part in ((":a", before), (":b", after)): + poly = polyline_entity(drawing_id, part, DESIGN_LAYER_ID, DESIGN_COLOR, suffix) + if poly: + entities.append(poly) + structure = polyline_entity(drawing_id, ditch_part, STRUCTURE_LAYER_ID, STRUCTURE_COLOR) + if structure: + entities.append(structure) + return entities + + +# 횡단 수량 산출표 (납품 양식). 본문 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", +) + +_CROSS_TABLE_WIDTH = 26.0 +_CROSS_TABLE_ROW_HEIGHT = 1.6 +_CROSS_TABLE_FONT = 0.55 + +# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) +_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) -> list[float]: + total = sum(_CROSS_COLUMN_WEIGHTS) + left = -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 _cross_table_entities( + drawing_id: str, + quantity_table: dict[str, float | None], + table_top: float, + title_label: str, +) -> list[dict[str, Any]]: + """횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다.""" + width = _CROSS_TABLE_WIDTH + row_h = _CROSS_TABLE_ROW_HEIGHT + font = _CROSS_TABLE_FONT + left = -width / 2.0 + right = width / 2.0 + edges = _cross_column_edges(width) + entities: list[dict[str, Any]] = [] + + def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]: + value = quantity_table.get(seed_key) + return _text_entity( + f"{drawing_id}:qtable:{seed_key}", + _format(value) if isinstance(value, (int, float)) else "-", + x, + y, + CROSS_TABLE_LAYER_ID, + font, + TABLE_VALUE_COLOR, + ) + + def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]: + return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR) + + def h_line(seed: str, x_from: float, x_to: float, y: float) -> None: + entities.append( + _line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) + ) + + def v_line(seed: str, x: float, y_from: float, y_to: float) -> None: + entities.append( + _line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR) + ) + + # ── 행 y 좌표 (제목행, 헤더행, 본문 6행) + y_title_top = table_top + y_header_top = y_title_top - row_h + y_body_top = y_header_top - row_h + body_rows = len(_CROSS_LEFT_ROWS) + y_bottom = y_body_top - row_h * body_rows + + def body_y(row_index: int) -> float: + return y_body_top - row_h * row_index + + # ── 외곽/가로선 + h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top) + h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top) + h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top) + h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom) + v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom) + v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom) + + # 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다. + for boundary in range(1, body_rows): + y = body_y(boundary) + left_merged = _CROSS_LEFT_ROWS[boundary][0] is None + middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None + seed = f"{drawing_id}:qgrid:b{boundary}" + if left_merged: + h_line(f"{seed}:l", edges[1], edges[4], y) + else: + h_line(f"{seed}:l", edges[0], edges[4], y) + if middle_merged: + h_line(f"{seed}:m", edges[5], edges[8], y) + else: + h_line(f"{seed}:m", edges[4], edges[8], y) + h_line(f"{seed}:r", edges[8], edges[11], y) + + # ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할 + header_cell = width / 8.0 + for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS): + x_label = left + header_cell * (pair_index * 2 + 0.5) + x_value = left + header_cell * (pair_index * 2 + 1.5) + y_mid = y_header_top - row_h * 0.5 + entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid)) + entities.append(value_text(key, x_value, y_mid)) + if pair_index > 0: + v_line( + f"{drawing_id}:qgrid:hv{pair_index}", + left + header_cell * pair_index * 2, + y_header_top, + y_body_top, + ) + v_line( + f"{drawing_id}:qgrid:hvl{pair_index}", + left + header_cell * (pair_index * 2 + 1), + y_header_top, + y_body_top, + ) + + # ── 제목행 (No.측점) + entities.append( + _text_entity( + f"{drawing_id}:qtitle", + title_label, + left + width * 0.03, + y_title_top - row_h * 0.5, + CROSS_TABLE_LAYER_ID, + font * 1.15, + TABLE_LABEL_COLOR, + align="left", + ) + ) + + # ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만. + ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None] + if ab_rows: + v_line( + f"{drawing_id}:qgrid:vab", + edges[1], + body_y(min(ab_rows)), + body_y(max(ab_rows) + 1), + ) + for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10): + v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom) + + # ── 본문 셀 (좌/중/우 그룹) + def cell_mid(edge_from: int, edge_to: int) -> float: + return (edges[edge_from] + edges[edge_to]) / 2.0 + + for row_index in range(body_rows): + y_mid = body_y(row_index) - row_h * 0.5 + group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] + if group_l is not None and sub_l is not None: + # 그룹 라벨은 2행 병합 중앙 배치 + y_group = body_y(row_index) - row_h # 병합 2행의 중앙 + entities.append( + label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group) + ) + elif group_l is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid) + ) + if sub_l is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid) + ) + if key_l is not None: + entities.append(value_text(key_l, cell_mid(2, 3), y_mid)) + + group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] + if group_m is not None: + y_group = body_y(row_index) - row_h + entities.append( + label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group) + ) + entities.append( + label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid) + ) + entities.append(value_text(key_m, cell_mid(6, 7), y_mid)) + + label_r, key_r = _CROSS_RIGHT_ROWS[row_index] + if label_r is not None: + entities.append( + label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid) + ) + if key_r is not None: + entities.append(value_text(key_r, cell_mid(9, 10), y_mid)) + return entities + + +def build_cross_drawing( + source: dict[str, Any], + drawing_id: str, + design_line: list[Any] | None, + design: dict[str, Any] | None, + quantity_table: dict[str, float | None] | None, + title_label: str, + design_elevation_m: float | None = None, + frame: dict[str, float] | None = None, +) -> dict[str, Any]: + """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. + + 로컬 좌표 정규화: 계획고(design_elevation_m)를 y=0으로 두어 모든 측점 + 도면이 같은 화면 배치를 갖는다. + + 배치 규약: 이 단면의 선(지표+설계+암 경계) 전체 bbox 중심을 y=0에 두어 + 측점마다 콘텐츠가 화면 중앙에 온다(종단 경사 드리프트 제거). 테이블은 + frame(노선 공통 최대 반높이 half_height)이 오면 전 측점 동일 y에 고정해 + Fit-in-all 배율·중심이 측점 간 흔들리지 않게 한다. design_elevation_m는 + 현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. + """ + raw_ground = points_from_samples(source.get("samples", []), "offset_m") + raw_design: list[tuple[float, float]] = [] + for point in design_line if isinstance(design_line, list) else []: + if not isinstance(point, dict): + continue + x = point.get("offset_m") + y = point.get("elevation_m") + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + raw_design.append((float(x), float(y))) + rock_offset = design.get("rock_boundary_offset_m") if isinstance(design, dict) else None + + # 콘텐츠 bbox: 지표선 + 설계선 + 암 경계선(지표+오프셋) 표고 범위. + all_ys = [y for _x, y in raw_ground] + [y for _x, y in raw_design] + if isinstance(rock_offset, (int, float)): + all_ys.extend(y + float(rock_offset) for _x, y in raw_ground) + dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0 + own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0 + + ground_points = [(x, y - dy) for x, y in raw_ground] + entities: list[dict[str, Any]] = [] + ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR) + if ground: + entities.append(ground) + entities.extend(_cross_line_entities(drawing_id, design_line, design, dy)) + + # 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재. + if isinstance(rock_offset, (int, float)) and ground_points: + rock_points = [(x, y + float(rock_offset)) for x, y in ground_points] + rock = polyline_entity(drawing_id, rock_points, ROCK_LAYER_ID, ROCK_COLOR, dash=[6, 4]) + if rock: + entities.append(rock) + + # 테이블 상단: 노선 공통 최대 반높이 아래 고정(전 측점 동일). frame 없으면 자체 폴백. + half_height = ( + frame["half_height"] if frame and "half_height" in frame else own_half_height + 1.0 + ) + table_bottom = -half_height + if quantity_table is not None: + table_top = -half_height - 2.0 + entities.extend(_cross_table_entities(drawing_id, quantity_table, table_top, title_label)) + table_bottom = table_top - _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) + + # 외곽 테두리: 전 측점 동일 크기 사각형(노선 공통 half/half_height 기준) — + # Fit-in-all 바운딩박스를 고정해 측점 이동 시 배율·중심이 흔들리지 않는다. + half = frame["half"] if frame else max((abs(x) for x, _y in ground_points), default=12.0) + frame_x = max(half + 2.0, _CROSS_TABLE_WIDTH / 2.0 + 1.0) + frame_top = half_height + 1.0 + frame_bottom = table_bottom - 1.0 + corners = [ + (-frame_x, frame_bottom), + (frame_x, frame_bottom), + (frame_x, frame_top), + (-frame_x, frame_top), + (-frame_x, frame_bottom), + ] + border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) + if border: + entities.append(border) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(GROUND_LAYER_ID, "Existing Ground", locked=True), + _layer(DESIGN_LAYER_ID, "Design Plan"), + _layer(STRUCTURE_LAYER_ID, "Structure"), + _layer(ROCK_LAYER_ID, "Rock Boundary"), + _layer(CROSS_TABLE_LAYER_ID, "Quantity Table"), + _layer(FRAME_LAYER_ID, "Frame", locked=True), + ], + } + + +def extract_quantity_table( + drawing_id: str, drawing: dict[str, Any] +) -> dict[str, float | None] | None: + """확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다. + + 값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을 + 반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다. + """ + id_to_key = { + str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS + } + entities = drawing.get("entities") + if not isinstance(entities, list): + return None + table: dict[str, float | None] = {} + found = False + for entity in entities: + if not isinstance(entity, dict): + continue + key = id_to_key.get(str(entity.get("id"))) + if key is None: + continue + found = True + 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 + if not found: + 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 diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py new file mode 100644 index 00000000..8cc37350 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py @@ -0,0 +1,651 @@ +"""B07 종단도 CAD 조립 — 30측점 N분할 + 그래프 축·격자 + 하단 측점 테이블. + +납품 양식: + - 그래프 영역: 좌측 Y축(표고 눈금·라벨), 기준선(X축, 최저 표고에서 5m 이상 + 여유), 측점별 회색 세로선. + - 테이블 영역: 측점 세로선 없이 가로 구분선 위 눈금(틱)으로 측점 표현, + 값 텍스트는 세로쓰기. 행: 곡선/측점/거리/추가거리/지반고/계획고/절토고/ + 성토고/구배. + - 곡선행: 종단곡선 BVC·EVC 세로틱 + 수평선 + R/L 표기(브래킷형). + - 구배행: 구간 사선(상향/하향) + 가로 "구배% L=길이" + 구배 변화점에 + 원(내부 세로쓰기 계획고), 노선 시·종점은 반원. +""" + +import math +from typing import Any +from uuid import uuid5 + +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( + design_elevation_from_longitudinal, +) +from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad import ( + _ENTITY_NS, + DESIGN_COLOR, + DESIGN_LAYER_ID, + DRAWING_FORMAT, + GROUND_COLOR, + GROUND_LAYER_ID, + LONG_SPLIT_STATION_COUNT, + LONG_TABLE_LAYER_ID, + TABLE_LABEL_COLOR, + TABLE_LINE_COLOR, + TABLE_VALUE_COLOR, + _design_profile_points, + _format, + _interpolate, + _layer, + _line_entity, + _profile_alignment, + _stations, + _text_entity, + infer_station_interval, + points_from_samples, + polyline_entity, + station_no_label, +) + +# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외). +LONG_GRID_LAYER_ID = "b07-long-grid" +GRID_COLOR = "#5b6572" + +_VERTICAL = (0.0, 1.0) # 세로쓰기(아래→위) + +# 테이블 행: (키, 헤더 라벨, 행 높이 m). 값 세로쓰기 행은 높게 잡는다. +_ROW_TALL = 9.0 +_LONG_TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( + ("curve", "곡선", 5.0), + ("station", "측점", 7.0), + ("distance", "거리", 4.5), + ("cum_distance", "추가거리", _ROW_TALL), + ("ground", "지반고", _ROW_TALL), + ("design", "계획고", _ROW_TALL), + ("cut", "절토고", _ROW_TALL), + ("fill", "성토고", _ROW_TALL), + ("grade", "구배", 10.0), +) +_FONT_SIZE = 2.2 +_TICK_LEN = 0.8 # 측점 눈금 길이 + + +def _circle_entity( + seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Circle", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, + } + + +def _arc_entity( + seed: str, + center: tuple[float, float], + radius: float, + start_angle: float, + end_angle: float, + layer_id: str, + color: str, +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Arc", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "center": {"x": center[0], "y": center[1]}, + "radius": radius, + "startAngle": start_angle, + "endAngle": end_angle, + "counterClockwise": True, + }, + } + + +def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: + """측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}. + + 30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시 + 경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례). + """ + stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"])) + if not stations: + return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}] + if len(stations) <= LONG_SPLIT_STATION_COUNT: + return [ + { + "id": "longitudinal", + "label": "종단도", + "start_m": float(stations[0]["chainage_m"]), + "end_m": float(stations[-1]["chainage_m"]), + } + ] + chunks: list[dict[str, Any]] = [] + stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복 + index = 0 + number = 1 + while index < len(stations) - 1: + chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT] + chunks.append( + { + "id": f"longitudinal_{number}", + "label": f"종단도({number})", + "start_m": float(chunk_stations[0]["chainage_m"]), + "end_m": float(chunk_stations[-1]["chainage_m"]), + } + ) + index += stride + number += 1 + return chunks + + +def _long_table_values( + stations: list[dict[str, Any]], + all_stations: list[dict[str, Any]], + ground_points: list[tuple[float, float]], + longitudinal: dict[str, Any], + interval_m: float, +) -> list[dict[str, str]]: + """청크 측점별 테이블 셀 문자열. 거리는 노선 전체 기준 직전 측점과의 차.""" + chainage_all = sorted(float(s["chainage_m"]) for s in all_stations) + rows: list[dict[str, str]] = [] + for station in stations: + chainage = float(station["chainage_m"]) + ground = station.get("center_z") + ground = ( + float(ground) + if isinstance(ground, (int, float)) + else _interpolate(ground_points, chainage) + ) + design = design_elevation_from_longitudinal(longitudinal, chainage) + cut = max(ground - design, 0.0) if ground is not None and design is not None else None + fill = max(design - ground, 0.0) if ground is not None and design is not None else None + position = chainage_all.index(chainage) if chainage in chainage_all else -1 + distance = chainage - chainage_all[position - 1] if position > 0 else None + rows.append( + { + "station": station_no_label(chainage, interval_m), + "distance": _format(distance, 1), + "cum_distance": _format(chainage, 1), + "ground": _format(ground), + "design": _format(design), + "cut": _format(cut) if cut else "", + "fill": _format(fill) if fill else "", + } + ) + return rows + + +def _graph_grid_entities( + drawing_id: str, + chainages: list[float], + x0: float, + x1: float, + datum_y: float, + top_y: float, +) -> list[dict[str, Any]]: + """그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선.""" + entities: list[dict[str, Any]] = [ + # 기준선(X축) + _line_entity( + f"{drawing_id}:lgx", (x0, datum_y), (x1, datum_y), LONG_GRID_LAYER_ID, GRID_COLOR + ), + # Y축 + _line_entity( + f"{drawing_id}:lgy", (x0, datum_y), (x0, top_y), LONG_GRID_LAYER_ID, GRID_COLOR + ), + ] + # Y축 표고 눈금·라벨 (5m 간격, 가로쓰기) + level = datum_y + tick_index = 0 + while level <= top_y + 1e-6: + entities.append( + _line_entity( + f"{drawing_id}:lgyt{tick_index}", + (x0 - 1.0, level), + (x0, level), + LONG_GRID_LAYER_ID, + GRID_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:lgyl{tick_index}", + _format(level), + x0 - 1.6, + level, + LONG_GRID_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + level += 5.0 + tick_index += 1 + # 측점별 회색 세로선 (기준선 → 그래프 상단) + for index, x in enumerate(chainages): + entities.append( + _line_entity( + f"{drawing_id}:lgv{index}", + (x, datum_y), + (x, top_y), + LONG_GRID_LAYER_ID, + GRID_COLOR, + ) + ) + return entities + + +def _curve_row_entities( + drawing_id: str, + longitudinal: dict[str, Any], + x0: float, + x1: float, + y_top: float, + row_height: float, +) -> list[dict[str, Any]]: + """곡선행: 종단곡선(BVC~EVC) 세로틱+수평선+R/L 표기 (브래킷형).""" + entities: list[dict[str, Any]] = [] + curves = _profile_alignment(longitudinal).get("curves") + if not isinstance(curves, list): + return entities + y_line = y_top - row_height * 0.62 + y_text = y_top - row_height * 0.30 + tick = row_height * 0.24 + for index, curve in enumerate(curves): + if not isinstance(curve, dict) or curve.get("omitted"): + continue + bvc = curve.get("bvc_m") + evc = curve.get("evc_m") + if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)): + continue + start = max(float(bvc), x0) + end = min(float(evc), x1) + if end <= start: + continue + seed = f"{drawing_id}:lcurve:{index}" + entities.append( + _line_entity( + f"{seed}:l", (start, y_line), (end, y_line), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR + ) + ) + entities.append( + _line_entity( + f"{seed}:t0", + (start, y_line - tick), + (start, y_line + tick), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + entities.append( + _line_entity( + f"{seed}:t1", + (end, y_line - tick), + (end, y_line + tick), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + r_m = curve.get("r_m") + l_m = curve.get("l_m") + parts = [] + if isinstance(r_m, (int, float)): + parts.append(f"R={r_m:g}") + if isinstance(l_m, (int, float)): + parts.append(f"L={l_m:g}") + if parts: + entities.append( + _text_entity( + f"{seed}:txt", + " ".join(parts), + (start + end) / 2.0, + y_text, + LONG_TABLE_LAYER_ID, + _FONT_SIZE * 0.9, + TABLE_VALUE_COLOR, + ) + ) + return entities + + +def _grade_break_points( + segments: list[Any], x0: float, x1: float, route_start: float, route_end: float +) -> list[tuple[float, bool]]: + """구배 변화점 목록 [(chainage, 노선 시·종점 여부)] — 청크 범위 내만.""" + boundaries: set[float] = set() + for segment in segments: + if not isinstance(segment, dict): + continue + for key in ("from_m", "to_m"): + value = segment.get(key) + if isinstance(value, (int, float)) and x0 - 1e-6 <= float(value) <= x1 + 1e-6: + boundaries.add(round(float(value), 4)) + return [ + (chainage, abs(chainage - route_start) < 1e-3 or abs(chainage - route_end) < 1e-3) + for chainage in sorted(boundaries) + ] + + +def _grade_row_entities( + drawing_id: str, + longitudinal: dict[str, Any], + stations: list[dict[str, Any]], + x0: float, + x1: float, + y_top: float, + row_height: float, + route_start: float, + route_end: float, +) -> list[dict[str, Any]]: + """구배행: 구간 사선 + 가로 구배 표기 + 변화점 원(내부 세로쓰기 계획고). + + 노선 시·종점은 반원(상반원)으로 표기한다. profile_alignment.segments가 + 없으면(구 폴백 계획선) 측점 간 계획고 차이 텍스트만 표기한다. + """ + entities: list[dict[str, Any]] = [] + y_mid = y_top - row_height * 0.5 + y_high = y_top - row_height * 0.18 + y_low = y_top - row_height * 0.82 + radius = row_height * 0.40 + segments = _profile_alignment(longitudinal).get("segments") + if not (isinstance(segments, list) and segments): + # 폴백: 측점 간 계획고 차이 기반 구배 텍스트 + for index in range(len(stations) - 1): + c0 = float(stations[index]["chainage_m"]) + c1 = float(stations[index + 1]["chainage_m"]) + d0 = design_elevation_from_longitudinal(longitudinal, c0) + d1 = design_elevation_from_longitudinal(longitudinal, c1) + span = c1 - c0 + if d0 is None or d1 is None or span <= 0: + continue + entities.append( + _text_entity( + f"{drawing_id}:lgrade:fb{index}", + f"{(d1 - d0) / span * 100:.2f}%", + (c0 + c1) / 2.0, + y_mid, + LONG_TABLE_LAYER_ID, + _FONT_SIZE * 0.9, + TABLE_VALUE_COLOR, + ) + ) + return entities + + # 구간 사선 + 가로 구배/거리 표기 (원 반경만큼 안쪽으로 클리핑) + for index, segment in enumerate(segments): + if not isinstance(segment, dict): + continue + from_m = segment.get("from_m") + to_m = segment.get("to_m") + grade = segment.get("grade_percent") + if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)): + continue + start = max(float(from_m), x0) + radius + end = min(float(to_m), x1) - radius + if end <= start: + continue + seed = f"{drawing_id}:lgrade:{index}" + rising = isinstance(grade, (int, float)) and grade >= 0 + entities.append( + _line_entity( + f"{seed}:d", + (start, y_low if rising else y_high), + (end, y_high if rising else y_low), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + if isinstance(grade, (int, float)): + length = segment.get("length_m") + length_text = f" L={length:g}" if isinstance(length, (int, float)) else "" + entities.append( + _text_entity( + f"{seed}:txt", + f"{grade:.2f}%{length_text}", + (start + end) / 2.0, + y_high + 0.2 if rising else y_low - 0.2, + LONG_TABLE_LAYER_ID, + _FONT_SIZE * 0.85, + TABLE_VALUE_COLOR, + ) + ) + + # 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고 + for index, (chainage, is_route_end) in enumerate( + _grade_break_points(segments, x0, x1, route_start, route_end) + ): + seed = f"{drawing_id}:lgradebp:{index}" + if is_route_end: + entities.append( + _arc_entity( + seed, + (chainage, y_mid), + radius, + 0.0, + math.pi, + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + else: + entities.append( + _circle_entity( + seed, (chainage, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR + ) + ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) + if elevation is not None: + entities.append( + _text_entity( + f"{seed}:txt", + _format(elevation), + chainage, + y_mid, + LONG_TABLE_LAYER_ID, + _FONT_SIZE * 0.8, + TABLE_VALUE_COLOR, + direction=_VERTICAL, + ) + ) + return entities + + +def _long_table_entities( + drawing_id: str, + stations: list[dict[str, Any]], + values: list[dict[str, str]], + longitudinal: dict[str, Any], + table_top: float, + interval_m: float, + route_start: float, + route_end: float, +) -> list[dict[str, Any]]: + """종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값.""" + chainages = [float(s["chainage_m"]) for s in stations] + header_width = max(interval_m, 12.0) + left = chainages[0] - header_width + right = chainages[-1] + entities: list[dict[str, Any]] = [] + + # 행 y 경계 (가변 행 높이) + boundaries = [table_top] + for _key, _header, height in _LONG_TABLE_ROWS: + boundaries.append(boundaries[-1] - height) + bottom = boundaries[-1] + + # 가로 구분선 + 측점 눈금(구분선 아래 짧은 틱) + for row_index, y in enumerate(boundaries): + entities.append( + _line_entity( + f"{drawing_id}:ltgrid:h{row_index}", + (left, y), + (right, y), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + if row_index < len(boundaries) - 1: + for column_index, x in enumerate(chainages): + entities.append( + _line_entity( + f"{drawing_id}:lttick:{row_index}:{column_index}", + (x, y), + (x, y - _TICK_LEN), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + # 외곽·헤더 구분 세로선 (측점 세로선은 없음) + for seed, x in (("v-left", left), ("v-header", chainages[0]), ("v-right", right)): + entities.append( + _line_entity( + f"{drawing_id}:ltgrid:{seed}", + (x, table_top), + (x, bottom), + LONG_TABLE_LAYER_ID, + TABLE_LINE_COLOR, + ) + ) + + for row_index, (key, header, height) in enumerate(_LONG_TABLE_ROWS): + y_top = boundaries[row_index] + y_mid = y_top - height * 0.5 + entities.append( + _text_entity( + f"{drawing_id}:ltable:header:{key}", + header, + left + header_width / 2.0, + y_mid, + LONG_TABLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + if key == "curve": + entities.extend( + _curve_row_entities(drawing_id, longitudinal, chainages[0], right, y_top, height) + ) + continue + if key == "grade": + entities.extend( + _grade_row_entities( + drawing_id, + longitudinal, + stations, + chainages[0], + right, + y_top, + height, + route_start, + route_end, + ) + ) + continue + for value_index, row_values in enumerate(values): + label = row_values.get(key, "") + if not label: + continue + if key == "distance": + # 거리는 직전 측점과의 구간 중앙, 가로쓰기 (청크 첫 측점 제외) + if value_index == 0: + continue + entities.append( + _text_entity( + f"{drawing_id}:ltable:{key}:{value_index}", + label, + (chainages[value_index - 1] + chainages[value_index]) / 2.0, + y_mid, + LONG_TABLE_LAYER_ID, + _FONT_SIZE * 0.9, + TABLE_VALUE_COLOR, + ) + ) + continue + # 측점·추가거리·지반고·계획고·절토고·성토고: 측점 위치 세로쓰기 + entities.append( + _text_entity( + f"{drawing_id}:ltable:{key}:{value_index}", + label, + chainages[value_index], + y_mid, + LONG_TABLE_LAYER_ID, + _FONT_SIZE, + TABLE_VALUE_COLOR, + direction=_VERTICAL, + ) + ) + return entities + + +def build_longitudinal_drawing( + longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] +) -> dict[str, Any]: + """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" + start_m = chunk.get("start_m") + end_m = chunk.get("end_m") + + def in_range(x: float) -> bool: + if start_m is None or end_m is None: + return True + return start_m - 1e-6 <= x <= end_m + 1e-6 + + ground_all = points_from_samples(longitudinal.get("samples", []), "chainage_m") + ground_points = [point for point in ground_all if in_range(point[0])] + design_points = [point for point in _design_profile_points(longitudinal) if in_range(point[0])] + all_stations = _stations(longitudinal) + stations = sorted( + (s for s in all_stations if in_range(float(s["chainage_m"]))), + key=lambda s: float(s["chainage_m"]), + ) + interval_m = infer_station_interval(all_stations) + + entities: list[dict[str, Any]] = [] + ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR) + if ground: + entities.append(ground) + design = polyline_entity(drawing_id, design_points, DESIGN_LAYER_ID, DESIGN_COLOR) + if design: + entities.append(design) + + if stations: + chainages = [float(s["chainage_m"]) for s in stations] + elevations = [y for _x, y in [*ground_points, *design_points]] + min_e = min(elevations) if elevations else 0.0 + max_e = max(elevations) if elevations else 10.0 + # 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림. + datum_y = math.floor((min_e - 5.0) / 5.0) * 5.0 + top_y = max_e + 3.0 + entities.extend( + _graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_y, top_y) + ) + + values = _long_table_values(stations, all_stations, ground_all, longitudinal, interval_m) + all_chainages = sorted(float(s["chainage_m"]) for s in all_stations) + table_top = datum_y - 3.0 # 그래프-테이블 영역 분리 간격 + entities.extend( + _long_table_entities( + drawing_id, + stations, + values, + longitudinal, + table_top, + interval_m, + all_chainages[0], + all_chainages[-1], + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(GROUND_LAYER_ID, "Existing Ground", locked=True), + _layer(DESIGN_LAYER_ID, "Design Plan"), + _layer(LONG_GRID_LAYER_ID, "Graph Grid", locked=True), + _layer(LONG_TABLE_LAYER_ID, "Station Table"), + ], + } diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index 32251cc7..5d2d95c6 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -6,7 +6,7 @@ import logging import re from pathlib import Path from typing import Any -from uuid import UUID, uuid5 +from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -23,6 +23,18 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_longitudinal_section, merge_cross_section_design_by_round, ) +from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + QUANTITY_VALUE_KEYS, + build_cross_drawing, + extract_quantity_table, + infer_station_interval, + station_no_label, +) +from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad_Long import ( + build_longitudinal_drawing, + longitudinal_chunks, +) from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import ( DesignDrawingConfirmRequest, DesignDrawingConfirmResponse, @@ -39,12 +51,7 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) _CROSS_ID = re.compile(r"^cross_(\d+)m$") -_GROUND_LAYER_ID = "b07-ground" -_GROUND_COLOR = "#f5f7fa" -# 계획선(계획 종단선/횡단 설계선) 레이어. 편집 최소화하되 구조물 부착·선 트림이 가능하도록 -# 잠금 해제로 두고, 지반선과 색을 구분한다(계획=amethyst 계열). -_DESIGN_LAYER_ID = "b07-design" -_DESIGN_COLOR = "#b794f6" +_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") _STAGE_DIR = "B07_wf4_DesignDetail" @@ -123,13 +130,15 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) manifest_drawings = _read_manifest(project_root)["drawings"] + # 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1). drawings = [ DesignDrawingItem( - id="longitudinal", + id=str(chunk["id"]), kind="longitudinal", - label="종단도 전체", - confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")), + label=str(chunk["label"]), + confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")), ) + for chunk in longitudinal_chunks(longitudinal) ] for path in _cross_files(longitudinal_path, longitudinal): match = _CROSS_ID.fullmatch(path.stem) @@ -149,115 +158,12 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra return drawings -def _line_entity( - drawing_id: str, - index: int, - start: tuple[float, float], - end: tuple[float, float], - layer_id: str = _GROUND_LAYER_ID, - color: str = _GROUND_COLOR, -) -> dict[str, Any]: - # layer_id를 seed에 포함해 지반선·계획선 자식 Line의 uuid 충돌을 막는다. - entity_id = str( - uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{layer_id}:{index}") - ) - return { - "id": entity_id, - "type": "Line", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": { - "startPoint": {"x": start[0], "y": start[1]}, - "endPoint": {"x": end[0], "y": end[1]}, - }, - } - - -def _points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]: - """유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m).""" - points: list[tuple[float, float]] = [] - for sample in samples: - if not isinstance(sample, dict) or not sample.get("valid", False): - continue - x = sample.get(x_key) - y = sample.get("elevation_m", sample.get("z")) - if isinstance(x, (int, float)) and isinstance(y, (int, float)): - points.append((float(x), float(y))) - return points - - -def _polyline_entity( - drawing_id: str, points: list[tuple[float, float]], layer_id: str, color: str -) -> dict[str, Any] | None: - """점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None).""" - if len(points) < 2: - return None - children = [ - _line_entity(drawing_id, index, points[index], points[index + 1], layer_id, color) - for index in range(len(points) - 1) - ] - return { - "id": str(uuid5(UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"), f"{drawing_id}:{layer_id}")), - "type": "PolyLine", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": None, - "children": children, - } - - -def _design_points( - source: dict[str, Any], kind: str, design_line: list[Any] | None -) -> list[tuple[float, float]]: - """계획선 점열을 만든다. 종단=design_profiles의 계획고, 횡단=설계 design_line. - - 계획선 샘플은 지반선과 달리 valid 플래그가 없어 좌표 유효성만으로 판정한다. - """ - if kind == "longitudinal": - profiles = source.get("design_profiles") - if not isinstance(profiles, list) or not profiles: - return [] - raw = profiles[0].get("samples", []) - x_key = "chainage_m" - else: - raw = design_line if isinstance(design_line, list) else [] - x_key = "offset_m" - points: list[tuple[float, float]] = [] - for point in raw: - if not isinstance(point, dict): - continue - x = point.get(x_key) - y = point.get("elevation_m") - if isinstance(x, (int, float)) and isinstance(y, (int, float)): - points.append((float(x), float(y))) - return points - - -# 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고, -# planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다. -_QUANTITY_ITEM_KEYS = ( - "cut_soil", - "cut_soft_rock", - "cut_rock", - "tree_removal", - "fill_slope_protection", - "cut_slope_protection", - "ditch_soil", - "ditch_soft_rock", - "ditch_rock", - "embankment", - "grubbing", - "surface_grading", -) - - def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: - """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다. + """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). - 아직 산정되지 않은 값은 None으로 두어 프론트 입력칸에서 사용자가 채운다. - 절토고/성토고(cut/fill)는 지반고·계획고에서 파생한 초기값이다. + center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. + 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 + 두어 CAD 테이블에서 사용자가 채운다. """ def num(value: Any) -> float | None: @@ -275,53 +181,47 @@ def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: "cut": cut, "fill": fill, } - for key in _QUANTITY_ITEM_KEYS: - table[key] = num(quantities.get(key)) + for key in QUANTITY_VALUE_KEYS: + table.setdefault(key, num(quantities.get(key))) return table -def _cad_drawing( - source: dict[str, Any], - drawing_id: str, - kind: str, - design_line: list[Any] | None = None, -) -> dict[str, Any]: - """B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다. +def _route_cross_frame( + longitudinal_path: Path, longitudinal: dict[str, Any] +) -> dict[str, float] | None: + """노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다. - 지반선(b07-ground)과 계획선(b07-design)을 각각 별도 레이어의 PolyLine으로 emit한다. - 계획선은 종단도=계획고(design_profiles), 횡단도=표준단면 design_line에서 만든다. + 측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기 + 콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블 + y 고정용). 실패 시 None(측점 자체 범위 폴백). """ - x_key = "chainage_m" if kind == "longitudinal" else "offset_m" - ground_points = _points_from_samples(source.get("samples", []), x_key) - design_points = _design_points(source, kind, design_line) - - entities: list[dict[str, Any]] = [] - ground = _polyline_entity(drawing_id, ground_points, _GROUND_LAYER_ID, _GROUND_COLOR) - if ground: - entities.append(ground) - design = _polyline_entity(drawing_id, design_points, _DESIGN_LAYER_ID, _DESIGN_COLOR) - if design: - entities.append(design) - - # 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다. - # 계획선 레이어는 편집 가능(잠금 해제)으로 두어 구조물 부착·선 트림을 허용한다. - return { - "entities": entities, - "layers": [ - { - "id": _GROUND_LAYER_ID, - "name": "Existing Ground", - "isVisible": True, - "isLocked": False, - }, - { - "id": _DESIGN_LAYER_ID, - "name": "Design Plan", - "isVisible": True, - "isLocked": False, - }, - ], - } + half = half_height = 0.0 + found = False + try: + for path in _cross_files(longitudinal_path, longitudinal): + source = _read_json(path) + min_e: float | None = None + max_e: float | None = None + for sample in source.get("samples", []): + if not isinstance(sample, dict) or not sample.get("valid", False): + continue + offset = sample.get("offset_m") + elevation = sample.get("elevation_m") + if isinstance(offset, (int, float)): + half = max(half, abs(float(offset))) + if isinstance(elevation, (int, float)): + value = float(elevation) + min_e = value if min_e is None else min(min_e, value) + max_e = value if max_e is None else max(max_e, value) + if min_e is not None and max_e is not None: + half_height = max(half_height, (max_e - min_e) / 2.0) + found = True + except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError): + return None + if not found: + return None + # 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영. + return {"half": half or 12.0, "half_height": half_height + 1.5} def _cross_design_line( @@ -358,17 +258,26 @@ def _read_drawing( manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" if manifest_entry.get("confirmed") and saved_path.is_file(): - kind = "longitudinal" if drawing_id == "longitudinal" else "cross" - label = str(manifest_entry.get("label") or drawing_id) - stored_table = manifest_entry.get("quantity_table") - table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None - return kind, label, _read_json(saved_path), True, table - if drawing_id == "longitudinal": + saved = _read_json(saved_path) + # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 + # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. + if saved.get("format") == DRAWING_FORMAT: + kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" + label = str(manifest_entry.get("label") or drawing_id) + stored_table = manifest_entry.get("quantity_table") + table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None + return kind, label, saved, True, table + if _LONG_ID.fullmatch(drawing_id): source = _read_json(longitudinal_path) + chunk = next( + (item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None + ) + if chunk is None: + raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.") return ( "longitudinal", - "종단도 전체", - _cad_drawing(source, drawing_id, "longitudinal"), + str(chunk["label"]), + build_longitudinal_drawing(source, drawing_id, chunk), False, None, ) @@ -381,12 +290,31 @@ def _read_drawing( source = _read_json(path) label = str(source.get("label") or drawing_id) design_line = _cross_design_line(longitudinal_path, source, stored_design) + quantity_table = _quantity_table(source) + # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) + longitudinal = _read_json(longitudinal_path) + interval = infer_station_interval(longitudinal.get("stations") or []) + title = station_no_label(float(source.get("chainage_m", 0.0)), interval) + # 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정. + design_elevation = design_elevation_from_longitudinal( + longitudinal, float(source.get("chainage_m", 0.0)) + ) + frame = _route_cross_frame(longitudinal_path, longitudinal) return ( "cross", label, - _cad_drawing(source, drawing_id, "cross", design_line), + build_cross_drawing( + source, + drawing_id, + design_line, + stored_design, + quantity_table, + title, + design_elevation, + frame, + ), False, - _quantity_table(source), + quantity_table, ) @@ -399,6 +327,8 @@ def _store_confirmed_drawing( ) -> bool: if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") + # CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다. + drawing = {"format": DRAWING_FORMAT, **drawing} drawings_dir = _design_root(project_root) / "drawings" drawings_dir.mkdir(parents=True, exist_ok=True) path = drawings_dir / f"{item.id}.json" @@ -545,6 +475,12 @@ async def confirm_design_drawing( item = next((candidate for candidate in items if candidate.id == drawing_id), None) if not item: raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") + # 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백. + quantity_table = ( + extract_quantity_table(drawing_id, request.drawing) or request.quantity_table + if item.kind == "cross" + else None + ) # 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관. all_confirmed = await asyncio.to_thread( _store_confirmed_drawing, @@ -552,7 +488,7 @@ async def confirm_design_drawing( item, request.drawing, {candidate.id for candidate in items if candidate.kind == "cross"}, - request.quantity_table, + quantity_table, ) # 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음). diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 160b4982..9f413cd8 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -179,6 +179,15 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { return L("B06_Design_Mode_BothFill"); } +/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ +function ditchLabel(design: CrossDesignInfo): string { + const ditch = design.ditch; + if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; + if (ditch.type === "l_type") + return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; + return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; +} + function infoRow(label: string, value: string): HTMLElement { const row = document.createElement("div"); row.className = "b07-info__row"; @@ -239,10 +248,7 @@ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HT infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), - infoRow( - L("B07_Info_Ditch"), - `${design.ditch.width_m.toFixed(2)}×${design.ditch.depth_m.toFixed(2)}m`, - ), + infoRow(L("B07_Info_Ditch"), ditchLabel(design)), infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), ); @@ -345,7 +351,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { title: drawing.kind === "cross" && typeof drawing.chainage_m === "number" ? stationLabel(drawing.chainage_m, stationInterval) - : "종단도 전체", + : drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: response.confirmed, quantityTable: response.quantity_table ?? null, diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx index af2dcbe6..7acd2e67 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx @@ -1,94 +1,15 @@ -import { type FC, useCallback, useEffect, useRef, useState } from 'react'; +import { type FC, useCallback, useEffect, useState } from 'react'; import { HtmlEvent } from '../App.types'; -import { - notifyDrawingChangedByTable, - requestDrawingNavigation, -} from '../integration/aislo-drawing-bridge'; -import { getDesignMeta, setDesignQuantityTable } from '../state'; - -/** 편집 가능한 항목 키 (cut/fill 은 지반고·계획고에서 파생되는 읽기전용). */ -const EDITABLE_KEYS = [ - 'ground', - 'planned', - 'cut_soil', - 'cut_soft_rock', - 'cut_rock', - 'tree_removal', - 'fill_slope_protection', - 'cut_slope_protection', - 'ditch_soil', - 'ditch_soft_rock', - 'ditch_rock', - 'embankment', - 'grubbing', - 'surface_grading', -] as const; - -function round2(value: number): number { - return Math.round(value * 100) / 100; -} - -function formatValue(value: number | null | undefined): string { - return typeof value === 'number' && Number.isFinite(value) ? String(round2(value)) : ''; -} - -function parseValue(raw: string): number | null { - const text = raw.trim(); - if (!text) return null; - const parsed = Number(text); - return Number.isFinite(parsed) ? parsed : null; -} - -/** 편집 값 문자열 → 저장용 수치 테이블 (파생 cut/fill 포함) */ -function toNumericTable(values: Record): Record { - const table: Record = {}; - for (const key of EDITABLE_KEYS) table[key] = parseValue(values[key] ?? ''); - const ground = table.ground; - const planned = table.planned; - if (typeof ground === 'number' && typeof planned === 'number') { - table.cut = Math.max(ground - planned, 0); - table.fill = Math.max(planned - ground, 0); - } else { - table.cut = null; - table.fill = null; - } - return table; -} - -interface ValueInputProps { - fieldKey: (typeof EDITABLE_KEYS)[number]; - colSpan: number; - values: Record; - onEdit: (key: string, value: string) => void; -} - -const ValueInput: FC = ({ fieldKey, colSpan, values, onEdit }) => ( - - onEdit(fieldKey, event.target.value)} - /> - -); - -const DerivedCell: FC<{ value: string }> = ({ value }) => ( - - - -); +import { requestDrawingNavigation } from '../integration/aislo-drawing-bridge'; +import { getDesignMeta } from '../state'; /** - * 횡단도 수량 산출표 (CAD 화면 내부, 하단 중심 접이식 패널). - * 헤더(제목·측점정보·확정상태·이전/다음)는 접어도 항상 보인다. + * 도면 내비게이션 바 (CAD 화면 하단 중심). + * 수량 산출표는 HTML 테이블 대신 도면 자체의 CAD 테이블 레이어(b07-cross-table)로 + * 렌더되므로, 여기서는 제목·측점정보·확정상태·이전/다음 이동만 담당한다. */ export const QuantityPanel: FC = () => { const [meta, setMeta] = useState(getDesignMeta()); - const [collapsed, setCollapsed] = useState(false); - const [values, setValues] = useState>({}); - const identityRef = useRef(''); const refresh = useCallback(() => setMeta(getDesignMeta()), []); useEffect(() => { @@ -96,35 +17,10 @@ export const QuantityPanel: FC = () => { return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh); }, [refresh]); - // 새 도면(측점)으로 바뀌면 입력값을 원본값으로 초기화한다. - const identity = meta ? `${meta.kind}:${meta.title}` : ''; - useEffect(() => { - if (!meta) return; - if (identityRef.current === identity) return; - identityRef.current = identity; - const next: Record = {}; - for (const key of EDITABLE_KEYS) next[key] = formatValue(meta.quantityTable?.[key]); - setValues(next); - }, [identity, meta]); - if (!meta) return null; - const handleEdit = (key: string, value: string) => { - const nextValues = { ...values, [key]: value }; - setValues(nextValues); - setDesignQuantityTable(toNumericTable(nextValues)); - notifyDrawingChangedByTable(); - }; - - const ground = parseValue(values.ground ?? ''); - const planned = parseValue(values.planned ?? ''); - const cutText = - ground !== null && planned !== null ? formatValue(Math.max(ground - planned, 0)) : ''; - const fillText = - ground !== null && planned !== null ? formatValue(Math.max(planned - ground, 0)) : ''; - return ( -
+
- {meta.kind === 'cross' && ( - - )}
- - {/* 표는 종단도에서도 DOM에 남겨 패널 폭을 통일한다 (CSS로만 숨김) */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 지반고 - - 계획고 - - 절토고 - - 성토고 -
- 흙깎기 - 토사 - 지장목제거 - - 옆도랑파기 - 토사
연암 - 비탈보호공 - 성토면연암
보통암절토면보통암
- 흙쌓기 - - 제근 - - 노면고르기 -
-
); }; diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx index a6052d77..e10d1b97 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -104,7 +104,7 @@ export const Toolbar: FC = () => { const [selectedCount, setSelectedCount] = useState(0); const [selectedType, setSelectedType] = useState('선택 없음'); const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.'); - const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('properties'); + const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('layers'); const [panelCollapsed, setPanelCollapsed] = useState(false); const [snap, setSnap] = useState(getSnapEnabled()); const [grid, setGrid] = useState(getGridEnabled()); diff --git a/B07_wf4_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_wf4_DesignDetail/openwebcad/src/entities/TextEntity.ts index 61aefa8c..33a8fb92 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -109,6 +109,15 @@ export class TextEntity implements Entity { Object.assign(this.options, newOptions); } + public getLabel(): string { + return this.label; + } + + /** 테이블 값 셀 등 기존 텍스트 내용을 직접 수정한다 (aislo 더블클릭 편집용). */ + public setLabel(newLabel: string): void { + this.label = newLabel; + } + public getShape(): Shape | null { return null; // TODO see why we need to get the shape out of an entity } diff --git a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 2788a612..720d9007 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,9 +1,14 @@ +import { Point } from '@flatten-js/core'; +import { type DesignMeta, HtmlEvent } from '../App.types.ts'; +import { TextEntity } from '../entities/TextEntity.ts'; import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts'; -import { type DesignMeta, HtmlEvent } from '../App.types.ts'; import { + getCanvas, getDesignMeta, + getEntities, + getLayers, getScreenCanvasDrawController, setActiveLayerId, setDesignMeta, @@ -51,6 +56,44 @@ export function notifyDrawingChangedByTable() { notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); } +/** + * 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다. + * CAD 테이블(측점 테이블·수량 산출표) 값 셀 수정 수단 — 잠금 레이어는 제외. + */ +function registerTextDoubleClickEdit() { + const canvas = getCanvas(); + if (!canvas) return; + canvas.addEventListener('dblclick', (event: MouseEvent) => { + const drawController = getScreenCanvasDrawController(); + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + const worldPoint = drawController.targetToWorld(screenPoint); + const lockedLayerIds = new Set( + getLayers() + .filter((layer) => layer.isLocked) + .map((layer) => layer.id) + ); + let closest: TextEntity | null = null; + let closestDistance = Number.MAX_SAFE_INTEGER; + for (const entity of getEntities()) { + if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; + const distanceInfo = entity.distanceTo(worldPoint); + if (distanceInfo && distanceInfo[0] < closestDistance) { + closestDistance = distanceInfo[0]; + closest = entity; + } + } + if (!closest) return; + const fontSize = closest.getTextOptions().fontSize; + const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); + if (closestDistance > tolerance) return; + const nextLabel = window.prompt('값 수정', closest.getLabel()); + if (nextLabel === null || nextLabel === closest.getLabel()) return; + closest.setLabel(nextLabel); + setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지 + }); +} + /** * B07 parent page와 CAD 앱 사이의 same-origin JSON 경계다. * DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다. @@ -92,6 +135,7 @@ export function registerAisloDrawingBridge() { window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => { notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); }); + registerTextDoubleClickEdit(); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts index 678cc7a1..3c397dd3 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts @@ -21,7 +21,7 @@ export const B08Api = { method: "PUT", body: JSON.stringify(data), }), - catalog: (p: string) => req(path(p, "/catalog")), + catalog: (p: string) => req(path(p, "/catalog")), savePriceBook: (p: string, data: any) => req(path(p, "/catalog/price-books"), { method: "POST", body: JSON.stringify(data) }), saveItem: (p: string, data: any) => @@ -58,4 +58,5 @@ export const B08Api = { method: "POST", }), runs: (p: string) => req(path(p, "/calculation-runs")), + latestCalculation: (p: string) => req(path(p, "/calculation-runs/latest")), }; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py index b4f1065a..4feaf63f 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py @@ -15,5 +15,5 @@ def calculate_indirect(policies: list[RatePolicy], initial: dict[str,int]) -> tu context[rule.rule_code]=amount results.append(IndirectResult(rule_code=rule.rule_code,rule_name=rule.rule_name, base_amount=round_amount(base,"ROUND",1),rate_value=rule.rate_value,result_amount=amount, - trace={"expression":rule.base_expression,"raw":str(raw),"rounding":rule.rounding_mode,"unit":rule.rounding_unit})) + trace={"rule_name":rule.rule_name,"expression":rule.base_expression,"raw":str(raw),"rounding":rule.rounding_mode,"unit":rule.rounding_unit})) return results,context \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py index 9122e935..eece87f8 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py @@ -1,23 +1,68 @@ from decimal import Decimal + from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import round_amount -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import CostResult, EquipmentRate, UnitCost +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import ( + CostResult, + EquipmentRate, + UnitCost, +) + def _calculate(components, mode: str, unit: int) -> CostResult: - buckets={"LABOR":Decimal(0),"MATERIAL":Decimal(0),"EXPENSE":Decimal(0)}; trace=[] - for component in sorted(components,key=lambda row:row.sort_order): - amount=component.quantity*component.unit_price - buckets[component.cost_type]+=amount - trace.append({"reference_id":component.reference_id,"quantity":str(component.quantity), - "unit_price":str(component.unit_price),"amount":str(amount),"cost_type":component.cost_type}) - labor=round_amount(buckets["LABOR"],mode,unit); material=round_amount(buckets["MATERIAL"],mode,unit) - expense=round_amount(buckets["EXPENSE"],mode,unit) - return CostResult(labor=labor,material=material,expense=expense,total=labor+material+expense,trace=trace) + buckets = {"LABOR": Decimal(0), "MATERIAL": Decimal(0), "EXPENSE": Decimal(0)} + trace = [] + for component in sorted(components, key=lambda row: row.sort_order): + amount = component.quantity * component.unit_price + buckets[component.cost_type] += amount + trace.append( + { + "reference_id": component.reference_id, + "quantity": str(component.quantity), + "unit_price": str(component.unit_price), + "amount": str(amount), + "cost_type": component.cost_type, + } + ) + labor = round_amount(buckets["LABOR"], mode, unit) + material = round_amount(buckets["MATERIAL"], mode, unit) + expense = round_amount(buckets["EXPENSE"], mode, unit) + return CostResult( + labor=labor, + material=material, + expense=expense, + total=labor + material + expense, + trace=trace, + ) + def calculate_unit_cost(model: UnitCost) -> CostResult: - if not model.components: raise ValueError(f"{model.name}: 구성요소가 없습니다.") - return _calculate(model.components,model.rounding_mode,model.rounding_unit) + if not model.components: + raise ValueError(f"{model.name}: 구성요소가 없습니다.") + return _calculate(model.components, model.rounding_mode, model.rounding_unit) + def calculate_equipment_rate(model: EquipmentRate) -> CostResult: - if model.annual_hours<=0: raise ValueError(f"{model.name}: 연간 가동시간이 필요합니다.") - if not model.components: raise ValueError(f"{model.name}: 손료·연료·운전원 구성요소가 없습니다.") - return _calculate(model.components,"FLOOR",1) \ No newline at end of file + if model.annual_hours <= 0: + raise ValueError(f"{model.name}: 연간 가동시간이 필요합니다.") + if not model.components: + raise ValueError(f"{model.name}: 연료·운전·정비 구성요소가 없습니다.") + component_cost = _calculate(model.components, "FLOOR", 1) + hourly_ownership = round_amount(model.equipment_price / model.annual_hours, "FLOOR", 1) + expense = component_cost.expense + hourly_ownership + trace = [ + { + "component_type": "EQUIPMENT_OWNERSHIP", + "equipment_price": str(model.equipment_price), + "annual_hours": str(model.annual_hours), + "amount": str(hourly_ownership), + "cost_type": "EXPENSE", + }, + *component_cost.trace, + ] + return CostResult( + labor=component_cost.labor, + material=component_cost.material, + expense=expense, + total=component_cost.labor + component_cost.material + expense, + trace=trace, + ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py index 7c035c76..f3ddf845 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py @@ -1,23 +1,134 @@ import json -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import BasisWorkspace,ExchangeRate,PriceSource,RatePolicy + +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import ( + BasisWorkspace, + ExchangeRate, + PriceSource, + RatePolicy, +) + class BasisRepository: - def __init__(self,connection): self.db=connection - async def load(self,project_id:str,version:str)->BasisWorkspace|None: - async with self.db.cursor() as c: - await c.execute("SELECT * FROM b08_basis_versions WHERE project_id=%s AND version=%s",(project_id,version)); head=await c.fetchone() - if not head:return None - await c.execute("SELECT * FROM b08_price_sources WHERE project_id=%s ORDER BY priority_no",(project_id,)); sources=await c.fetchall() - await c.execute("SELECT * FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s",(project_id,version)); rates=await c.fetchall() - await c.execute("SELECT * FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s ORDER BY sort_order",(project_id,version)); policies=await c.fetchall() - return BasisWorkspace(project_id=project_id,version=version,base_date=head["base_date"],region=head["region"],currency=head["currency"],status=head["status"],price_sources=[PriceSource.model_validate(x) for x in sources],exchange_rates=[ExchangeRate.model_validate(x) for x in rates],rate_policies=[RatePolicy(**{**x,"condition_json":json.loads(x["condition_json"])}) for x in policies]) - async def save(self,data:BasisWorkspace)->None: - async with self.db.cursor() as c: - await c.execute("""INSERT INTO b08_basis_versions(project_id,version,base_date,region,currency,status) VALUES(%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE base_date=VALUES(base_date),region=VALUES(region),currency=VALUES(currency),status=VALUES(status)""",(data.project_id,data.version,data.base_date,data.region,data.currency,data.status)) - await c.execute("DELETE FROM b08_price_sources WHERE project_id=%s",(data.project_id,)) - for x in data.price_sources: await c.execute("INSERT INTO b08_price_sources(project_id,source_code,source_name,priority_no,publisher,reference_date) VALUES(%s,%s,%s,%s,%s,%s)",(data.project_id,x.source_code,x.source_name,x.priority_no,x.publisher,x.reference_date)) - await c.execute("DELETE FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s",(data.project_id,data.version)) - for x in data.exchange_rates: await c.execute("INSERT INTO b08_exchange_rates(project_id,basis_version,currency,rate_to_krw,source_id,effective_from,effective_to) VALUES(%s,%s,%s,%s,%s,%s,%s)",(data.project_id,data.version,x.currency,x.rate_to_krw,x.source_id,x.effective_from,x.effective_to)) - await c.execute("DELETE FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s",(data.project_id,data.version)) - for x in data.rate_policies: await c.execute("""INSERT INTO b08_rate_policies(project_id,basis_version,rule_code,rule_name,base_expression,rate_value,minimum_amount,maximum_amount,rounding_mode,rounding_unit,condition_json,source_reference,status,sort_order) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",(data.project_id,data.version,x.rule_code,x.rule_name,x.base_expression,x.rate_value,x.minimum_amount,x.maximum_amount,x.rounding_mode,x.rounding_unit,json.dumps(x.condition_json,ensure_ascii=False),x.source_reference,x.status,x.sort_order)) - await self.db.commit() \ No newline at end of file + def __init__(self, connection): + self.db = connection + + async def load(self, project_id: str, version: str) -> BasisWorkspace | None: + async with self.db.cursor() as cursor: + await cursor.execute( + "SELECT * FROM b08_basis_versions WHERE project_id=%s AND version=%s", + (project_id, version), + ) + head = await cursor.fetchone() + if not head: + return None + await cursor.execute( + """SELECT * FROM b08_price_sources + WHERE project_id=%s AND status='ACTIVE' ORDER BY priority_no""", + (project_id,), + ) + sources = list(await cursor.fetchall()) + await cursor.execute( + "SELECT * FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s", + (project_id, version), + ) + rates = list(await cursor.fetchall()) + await cursor.execute( + """SELECT * FROM b08_rate_policies + WHERE project_id=%s AND basis_version=%s ORDER BY sort_order""", + (project_id, version), + ) + policies = list(await cursor.fetchall()) + return BasisWorkspace( + project_id=project_id, + version=version, + base_date=head["base_date"], + region=head["region"], + currency=head["currency"], + status=head["status"], + price_sources=[PriceSource.model_validate(row) for row in sources], + exchange_rates=[ExchangeRate.model_validate(row) for row in rates], + rate_policies=[self._policy(row) for row in policies], + ) + + async def save(self, data: BasisWorkspace, user_id: int | None) -> None: + async with self.db.cursor() as cursor: + await cursor.execute( + """INSERT INTO b08_basis_versions( + project_id,version,base_date,region,currency,status,confirmed_by,confirmed_at + ) VALUES(%s,%s,%s,%s,%s,%s,%s, + IF(%s='CONFIRMED',CURRENT_TIMESTAMP,NULL)) + ON DUPLICATE KEY UPDATE + base_date=VALUES(base_date),region=VALUES(region),currency=VALUES(currency), + status=VALUES(status),confirmed_by=VALUES(confirmed_by), + confirmed_at=VALUES(confirmed_at)""", + (data.project_id, data.version, data.base_date, data.region, data.currency, + data.status, user_id if data.status == "CONFIRMED" else None, data.status), + ) + await self._invalidate_dependents(cursor, data.project_id, data.version) + await cursor.execute( + "UPDATE b08_price_sources SET status='INACTIVE' WHERE project_id=%s", + (data.project_id,), + ) + for source in data.price_sources: + await cursor.execute( + """INSERT INTO b08_price_sources( + id,project_id,source_code,source_name,priority_no,publisher, + reference_date,status + ) VALUES(%s,%s,%s,%s,%s,%s,%s,'ACTIVE') + ON DUPLICATE KEY UPDATE source_name=VALUES(source_name), + priority_no=VALUES(priority_no),publisher=VALUES(publisher), + reference_date=VALUES(reference_date),status='ACTIVE'""", + (source.id, data.project_id, source.source_code, source.source_name, + source.priority_no, source.publisher, source.reference_date), + ) + await cursor.execute( + "DELETE FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s", + (data.project_id, data.version), + ) + for rate in data.exchange_rates: + await cursor.execute( + """INSERT INTO b08_exchange_rates( + project_id,basis_version,currency,rate_to_krw,source_id, + effective_from,effective_to + ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", + (data.project_id, data.version, rate.currency, rate.rate_to_krw, + rate.source_id, rate.effective_from, rate.effective_to), + ) + await cursor.execute( + "DELETE FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s", + (data.project_id, data.version), + ) + for policy in data.rate_policies: + await cursor.execute( + """INSERT INTO b08_rate_policies( + project_id,basis_version,rule_code,rule_name,base_expression, + rate_value,minimum_amount,maximum_amount,rounding_mode, + rounding_unit,condition_json,source_reference,status,sort_order + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (data.project_id, data.version, policy.rule_code, policy.rule_name, + policy.base_expression, policy.rate_value, policy.minimum_amount, + policy.maximum_amount, policy.rounding_mode, policy.rounding_unit, + json.dumps(policy.condition_json, ensure_ascii=False), + policy.source_reference, policy.status, policy.sort_order), + ) + await self.db.commit() + + @staticmethod + def _policy(row: dict) -> RatePolicy: + values = dict(row) + condition = values.get("condition_json") + values["condition_json"] = json.loads(condition) if isinstance(condition, str) else (condition or {}) + return RatePolicy.model_validate(values) + + @staticmethod + async def _invalidate_dependents(cursor, project_id: str, version: str) -> None: + await cursor.execute( + """UPDATE b08_price_books SET status='STALE' + WHERE project_id=%s AND basis_version=%s AND status='CONFIRMED'""", + (project_id, version), + ) + for table in ("b08_unit_costs", "b08_equipment_rates", "b08_cost_basis"): + await cursor.execute( + f"UPDATE {table} SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", + (project_id,), + ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py index 9e5aa6e9..3e9e230f 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py @@ -97,6 +97,55 @@ class CalculationRepository: total.expense, total_amount), ) + + async def latest_detail(self, project_id: str) -> dict | None: + async with self.db.cursor() as cursor: + await cursor.execute( + """SELECT * FROM b08_calculation_runs + WHERE project_id=%s AND status='COMPLETE' + ORDER BY created_at DESC LIMIT 1""", + (project_id,), + ) + run = await cursor.fetchone() + if not run: + return None + await cursor.execute( + "SELECT * FROM b08_estimate_lines WHERE run_id=%s ORDER BY id", + (run["id"],), + ) + lines = list(await cursor.fetchall()) + await cursor.execute( + "SELECT * FROM b08_cost_aggregates WHERE run_id=%s AND aggregate_type='TOTAL'", + (run["id"],), + ) + aggregate = await cursor.fetchone() or {} + await cursor.execute( + "SELECT * FROM b08_indirect_cost_results WHERE run_id=%s ORDER BY sort_order", + (run["id"],), + ) + indirect = list(await cursor.fetchall()) + await cursor.execute( + "SELECT * FROM b08_final_cost_results WHERE run_id=%s", + (run["id"],), + ) + final = await cursor.fetchone() + for line in lines: + line["trace"] = json.loads(line.pop("trace_json")) + for result in indirect: + result["trace"] = json.loads(result.pop("trace_json")) + result["rule_name"] = result["trace"].get("rule_name", result["rule_code"]) + final_trace = json.loads(final.pop("trace_json")) + final["trace"] = final_trace + final["indirect_results"] = indirect + totals = { + "labor": aggregate.get("labor_amount", 0), + "material": aggregate.get("material_amount", 0), + "expense": aggregate.get("expense_amount", 0), + "direct_cost": final["direct_cost"], + "government_material": final["government_material"], + "excluded_amount": final_trace.get("excluded_amount", 0), + } + return {"run_id": run["id"], "lines": lines, "totals": totals, "final": final} async def history(self, project_id: str) -> list[dict]: async with self.db.cursor() as cursor: await cursor.execute( diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py index 95e24ff1..7037f77d 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py @@ -124,7 +124,7 @@ class CalculationSourceRepository: LEFT JOIN b08_cost_basis cb ON q.reference_type='COST_BASIS' AND cb.id=q.reference_id AND cb.project_id=q.project_id AND cb.status='CONFIRMED' - WHERE q.project_id=%s AND q.status='CONFIRMED' AND q.excluded=0 + WHERE q.project_id=%s AND q.status='CONFIRMED' ORDER BY q.id """ @@ -139,7 +139,7 @@ class CalculationSourceRepository: material=Decimal(str(row["unit_material"] or 0)), expense=Decimal(str(row["unit_expense"] or 0)), ), - procurement_type=row["procurement_type"], + procurement_type="EXCLUDED" if row["excluded"] else row["procurement_type"], ) @staticmethod @@ -160,5 +160,5 @@ class CalculationSourceRepository: "unit_labor": str(row["unit_labor"] or 0), "unit_material": str(row["unit_material"] or 0), "unit_expense": str(row["unit_expense"] or 0), - "procurement_type": row["procurement_type"], + "procurement_type": "EXCLUDED" if row["excluded"] else row["procurement_type"], } \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py index 863d750d..e3b718ea 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py @@ -26,6 +26,19 @@ class CatalogRepository: """, (project_id, row.price_version, row.basis_version, row.name, row.status, row.effective_date), ) + if row.status == "CONFIRMED": + await cursor.execute( + "UPDATE b08_unit_costs SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", + (project_id,), + ) + await cursor.execute( + "UPDATE b08_equipment_rates SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", + (project_id,), + ) + await cursor.execute( + "UPDATE b08_cost_basis SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", + (project_id,), + ) await self.db.commit() async def list_items(self, project_id: str) -> list[dict]: @@ -48,6 +61,34 @@ class CatalogRepository: await cursor.execute(sql, (project_id,)) return list(await cursor.fetchall()) + async def workspace(self, project_id: str) -> dict: + items = await self.list_items(project_id) + async with self.db.cursor() as cursor: + await cursor.execute( + "SELECT * FROM b08_price_sources WHERE project_id=%s ORDER BY priority_no,id", + (project_id,), + ) + sources = list(await cursor.fetchall()) + await cursor.execute( + "SELECT * FROM b08_price_books WHERE project_id=%s ORDER BY effective_date DESC,id DESC", + (project_id,), + ) + price_books = list(await cursor.fetchall()) + await cursor.execute( + """SELECT e.*,i.item_code,i.item_name,s.source_name + FROM b08_price_entries e + JOIN b08_catalog_items i ON i.id=e.item_id AND i.project_id=e.project_id + JOIN b08_price_sources s ON s.id=e.source_id AND s.project_id=e.project_id + WHERE e.project_id=%s ORDER BY e.price_version,i.item_code,e.id""", + (project_id,), + ) + candidates = list(await cursor.fetchall()) + return { + "sources": sources, + "price_books": price_books, + "items": items, + "candidates": candidates, + } async def save_item(self, project_id: str, item: CatalogItem) -> str: item_id = item.id or str(uuid4()) async with self.db.cursor() as cursor: @@ -86,7 +127,7 @@ class CatalogRepository: await self.db.commit() return result - async def apply(self, project_id: str, version: str, row: AppliedPrice) -> None: + async def apply(self, project_id: str, version: str, row: AppliedPrice, user_id: int | None) -> None: async with self.db.cursor() as cursor: await cursor.execute( """SELECT id FROM b08_price_entries @@ -98,14 +139,15 @@ class CatalogRepository: await cursor.execute( """ INSERT INTO b08_applied_prices( - project_id,price_version,item_id,price_entry_id,applied_price,selection_reason - ) VALUES(%s,%s,%s,%s,%s,%s) + project_id,price_version,item_id,price_entry_id,applied_price,selection_reason, + approved_by,approved_at + ) VALUES(%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE price_entry_id=VALUES(price_entry_id), applied_price=VALUES(applied_price), - selection_reason=VALUES(selection_reason), approved_at=NULL + selection_reason=VALUES(selection_reason), approved_by=VALUES(approved_by), approved_at=CURRENT_TIMESTAMP """, (project_id, version, row.item_id, row.price_entry_id, - row.applied_price, row.selection_reason), + row.applied_price, row.selection_reason, user_id), ) await self._mark_dependents_stale(cursor, project_id, row.item_id) await self.db.commit() diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py index 7170d939..b7bf8a68 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py @@ -1,19 +1,47 @@ from decimal import Decimal + class PricingResolver: - def __init__(self,connection):self.db=connection - async def resolve(self,component_type:str,reference_id:str,cost_type:str)->Decimal: - queries={ - "CATALOG":("""SELECT a.applied_price labor_price,a.applied_price material_price,a.applied_price expense_price,i.cost_type FROM b08_applied_prices a JOIN b08_price_books b ON b.project_id=a.project_id AND b.price_version=a.price_version AND b.status='CONFIRMED' JOIN b08_catalog_items i ON i.id=a.item_id WHERE a.item_id=%s ORDER BY b.effective_date DESC LIMIT 1"""), - "UNIT_COST":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_unit_costs WHERE id=%s AND status='CONFIRMED'", - "EQUIPMENT":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_equipment_rates WHERE id=%s AND status='CONFIRMED'", - "COST_BASIS":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_cost_basis WHERE id=%s AND status='CONFIRMED'", - } - if component_type not in queries:raise ValueError(f"지원하지 않는 참조유형: {component_type}") - async with self.db.cursor() as c:await c.execute(queries[component_type],(reference_id,));row=await c.fetchone() - if not row:raise ValueError(f"확정 적용단가 없음: {component_type}/{reference_id}") - if component_type=="CATALOG" and row["cost_type"]!=cost_type:raise ValueError(f"품목 비용분류 불일치: {reference_id}") - return Decimal(str(row[{"LABOR":"labor_price","MATERIAL":"material_price","EXPENSE":"expense_price"}[cost_type]])) - async def hydrate(self,components): - for item in components:item.unit_price=await self.resolve(item.component_type,item.reference_id,item.cost_type) - return components \ No newline at end of file + def __init__(self, connection, project_id: str): + self.db = connection + self.project_id = project_id + + async def resolve(self, component_type: str, reference_id: str, cost_type: str) -> Decimal: + queries = { + "CATALOG": """SELECT a.applied_price AS labor_price, + a.applied_price AS material_price,a.applied_price AS expense_price,i.cost_type + FROM b08_applied_prices a + JOIN b08_price_books b ON b.project_id=a.project_id + AND b.price_version=a.price_version AND b.status='CONFIRMED' + JOIN b08_catalog_items i ON i.id=a.item_id AND i.project_id=a.project_id + WHERE a.project_id=%s AND a.item_id=%s + ORDER BY b.effective_date DESC LIMIT 1""", + "UNIT_COST": """SELECT labor_price,material_price,expense_price,NULL AS cost_type + FROM b08_unit_costs WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", + "EQUIPMENT": """SELECT labor_price,material_price,expense_price,NULL AS cost_type + FROM b08_equipment_rates WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", + "COST_BASIS": """SELECT labor_price,material_price,expense_price,NULL AS cost_type + FROM b08_cost_basis WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", + } + if component_type not in queries: + raise ValueError(f"지원하지 않는 참조유형: {component_type}") + async with self.db.cursor() as cursor: + await cursor.execute(queries[component_type], (self.project_id, reference_id)) + row = await cursor.fetchone() + if not row: + raise ValueError(f"확정 적용단가 없음: {component_type}/{reference_id}") + if component_type == "CATALOG" and row["cost_type"] != cost_type: + raise ValueError(f"품목 비용분류 불일치: {reference_id}") + column = { + "LABOR": "labor_price", + "MATERIAL": "material_price", + "EXPENSE": "expense_price", + }[cost_type] + return Decimal(str(row[column])) + + async def hydrate(self, components): + for item in components: + item.unit_price = await self.resolve( + item.component_type, item.reference_id, item.cost_type + ) + return components \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py index 33275540..9a651ba6 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py @@ -166,8 +166,8 @@ class QuantityRepository: (user_id, project_id), ) snapshot = [ - {"id": row["id"], "quantity": row["adjusted_quantity"] or row["design_quantity"]} - for row in rows + {"id": row["id"], "quantity": row["adjusted_quantity"] if row["adjusted_quantity"] is not None else row["design_quantity"]} + for row in sorted(rows, key=lambda item: item["id"]) ] payload = json.dumps(snapshot, default=str, sort_keys=True).encode() digest = hashlib.sha256(payload).hexdigest() diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router.py index e6e727c3..5ae0796b 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router.py @@ -9,8 +9,8 @@ from B08_wf5_Quantity.B08_wf5_Quantity_Router_Reconciliation import router_for a def create_b08_router(connection_provider,user_provider)->APIRouter: router=APIRouter(prefix="/api/b08",tags=["B08 Quantity & Cost"]) - router.include_router(basis_router(connection_provider)) - router.include_router(catalog_router(connection_provider)) + router.include_router(basis_router(connection_provider,user_provider)) + router.include_router(catalog_router(connection_provider,user_provider)) router.include_router(costing_router(connection_provider)) router.include_router(quantity_router(connection_provider,user_provider)) router.include_router(calculation_router(connection_provider,user_provider)) diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py index ccbd45a0..9e2727f1 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py @@ -1,16 +1,30 @@ -from fastapi import APIRouter,Depends,HTTPException +from fastapi import APIRouter, Depends, HTTPException + from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Basis import BasisRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import BasisWorkspace -def router_for(connection_provider): - r=APIRouter() - @r.get("/{project_id}/basis/{version}",response_model=BasisWorkspace) - async def get(project_id:str,version:str,db=Depends(connection_provider)): - data=await BasisRepository(db).load(project_id,version) - if not data:raise HTTPException(404,"기준정보가 없습니다.") - return data - @r.put("/{project_id}/basis/{version}",response_model=BasisWorkspace) - async def put(project_id:str,version:str,data:BasisWorkspace,db=Depends(connection_provider)): - if data.project_id!=project_id or data.version!=version:raise HTTPException(422,"경로와 기준정보 ID가 다릅니다.") - await BasisRepository(db).save(data);return data - return r \ No newline at end of file + +def router_for(connection_provider, user_provider): + router = APIRouter() + + @router.get("/{project_id}/basis/{version}", response_model=BasisWorkspace) + async def get(project_id: str, version: str, db=Depends(connection_provider)): + data = await BasisRepository(db).load(project_id, version) + if not data: + raise HTTPException(404, "기준정보가 없습니다.") + return data + + @router.put("/{project_id}/basis/{version}", response_model=BasisWorkspace) + async def put( + project_id: str, + version: str, + data: BasisWorkspace, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + if data.project_id != project_id or data.version != version: + raise HTTPException(422, "경로와 기준정보 ID가 다릅니다.") + await BasisRepository(db).save(data, user_id) + return data + + return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py index 9eb958a8..24f444e9 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py @@ -52,6 +52,9 @@ def router_for(connection_provider, user_provider): run_id=run_id, lines=lines, totals=totals, final=final ) + @router.get("/{project_id}/calculation-runs/latest") + async def latest(project_id: str, db=Depends(connection_provider)): + return await CalculationRepository(db).latest_detail(project_id) @router.get("/{project_id}/calculation-runs") async def history(project_id: str, db=Depends(connection_provider)): return await CalculationRepository(db).history(project_id) diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py index a8b92f86..269bce7c 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py @@ -1,24 +1,45 @@ from fastapi import APIRouter, Depends -from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Catalog import CatalogRepository -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import AppliedPrice, CatalogItem, PriceBook, PriceCandidate -def router_for(connection_provider): +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Catalog import CatalogRepository +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import ( + AppliedPrice, + CatalogItem, + PriceBook, + PriceCandidate, +) + + +def router_for(connection_provider, user_provider): router = APIRouter() + @router.post("/{project_id}/catalog/price-books") async def save_book(project_id: str, data: PriceBook, db=Depends(connection_provider)): await CatalogRepository(db).save_book(project_id, data) return {"status": "ok"} + @router.get("/{project_id}/catalog") async def get_catalog(project_id: str, db=Depends(connection_provider)): - return await CatalogRepository(db).list_items(project_id) + return await CatalogRepository(db).workspace(project_id) + @router.post("/{project_id}/catalog/items") async def save_item(project_id: str, data: CatalogItem, db=Depends(connection_provider)): return {"id": await CatalogRepository(db).save_item(project_id, data)} + @router.post("/{project_id}/catalog/{version}/candidates") - async def add_candidate(project_id: str, version: str, data: PriceCandidate, db=Depends(connection_provider)): + async def add_candidate( + project_id: str, version: str, data: PriceCandidate, db=Depends(connection_provider) + ): return {"id": await CatalogRepository(db).add_candidate(project_id, version, data)} + @router.post("/{project_id}/catalog/{version}/apply") - async def apply_price(project_id: str, version: str, data: AppliedPrice, db=Depends(connection_provider)): - await CatalogRepository(db).apply(project_id, version, data) + async def apply_price( + project_id: str, + version: str, + data: AppliedPrice, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + await CatalogRepository(db).apply(project_id, version, data, user_id) return {"status": "ok"} + return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py index 2ec8c0e2..5e8dd6a5 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py @@ -13,11 +13,11 @@ def router_for(connection_provider): async def get(project_id:str,db=Depends(connection_provider)):return {**await UnitCostRepository(db).list_all(project_id),"cost_basis":await CostBasisRepository(db).list_all(project_id)} @r.post("/{project_id}/unit-costs",response_model=CostResult) async def unit(project_id:str,data:UnitCost,db=Depends(connection_provider)): - data.components=await PricingResolver(db).hydrate(data.components);result=calculate_unit_cost(data);await UnitCostRepository(db).save_unit_cost(project_id,data,result);return result + data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_unit_cost(data);await UnitCostRepository(db).save_unit_cost(project_id,data,result);return result @r.post("/{project_id}/equipment-rates",response_model=CostResult) async def equipment(project_id:str,data:EquipmentRate,db=Depends(connection_provider)): - data.components=await PricingResolver(db).hydrate(data.components);result=calculate_equipment_rate(data);await UnitCostRepository(db).save_equipment(project_id,data,result);return result + data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_equipment_rate(data);await UnitCostRepository(db).save_equipment(project_id,data,result);return result @r.post("/{project_id}/cost-basis",response_model=CostResult) async def basis(project_id:str,data:CostBasis,db=Depends(connection_provider)): - data.components=await PricingResolver(db).hydrate(data.components);result=calculate_cost_basis(data);await CostBasisRepository(db).save(project_id,data,result);return result + data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_cost_basis(data);await CostBasisRepository(db).save(project_id,data,result);return result return r \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py index 64fb1fec..44558343 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py @@ -1,16 +1,36 @@ -from fastapi import APIRouter,Depends -from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Quantity import QuantityRepository -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import DesignQuantity,WorkBreakdown +from fastapi import APIRouter, Depends -def router_for(connection_provider,user_provider): - r=APIRouter() - @r.get("/{project_id}/quantities") - async def get(project_id:str,db=Depends(connection_provider)):return await QuantityRepository(db).workspace(project_id) - @r.post("/{project_id}/work-breakdown") - async def wbs(project_id:str,data:WorkBreakdown,db=Depends(connection_provider)):return {"id":await QuantityRepository(db).save_wbs(project_id,data)} - @r.post("/{project_id}/quantities") - async def quantity(project_id:str,data:DesignQuantity,db=Depends(connection_provider),user_id=Depends(user_provider)):return {"id":await QuantityRepository(db).save_quantity(project_id,data,user_id)} - @r.post("/{project_id}/quantities/confirm") - async def confirm(project_id:str,db=Depends(connection_provider),user_id=Depends(user_provider)): - await QuantityRepository(db).confirm(project_id,user_id);return {"status":"confirmed"} - return r \ No newline at end of file +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Quantity import QuantityRepository +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import DesignQuantity, WorkBreakdown + + +def router_for(connection_provider, user_provider): + router = APIRouter() + + @router.get("/{project_id}/quantities") + async def get(project_id: str, db=Depends(connection_provider)): + return await QuantityRepository(db).workspace(project_id) + + @router.post("/{project_id}/work-breakdown") + async def save_wbs(project_id: str, data: WorkBreakdown, db=Depends(connection_provider)): + return {"id": await QuantityRepository(db).save_wbs(project_id, data)} + + @router.post("/{project_id}/quantities") + async def save_quantity( + project_id: str, + data: DesignQuantity, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + return {"id": await QuantityRepository(db).save_quantity(project_id, data, user_id)} + + @router.post("/{project_id}/quantities/confirm") + async def confirm( + project_id: str, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + version = await QuantityRepository(db).confirm(project_id, user_id) + return {"status": "confirmed", "quantity_version": version} + + return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts index 4ede7810..2a016f7e 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts @@ -3,7 +3,7 @@ import { B08State, TabId, WorkspaceData } from "./B08_wf5_Quantity_Types"; const empty = (): WorkspaceData => ({ basis: null, - catalog: [], + catalog: { sources: [], price_books: [], items: [], candidates: [] }, costing: { unit_costs: [], equipment_rates: [], cost_basis: [] }, quantities: { work_breakdown: [], quantities: [], confirmation: null }, runs: [], @@ -12,7 +12,7 @@ const empty = (): WorkspaceData => ({ export class B08Store { state: B08State; - private listeners = new Set<(s: B08State) => void>(); + private listeners = new Set<(state: B08State) => void>(); constructor(projectId: string) { this.state = { @@ -25,10 +25,10 @@ export class B08Store { }; } - subscribe(fn: (s: B08State) => void) { - this.listeners.add(fn); - fn(this.state); - return () => this.listeners.delete(fn); + subscribe(listener: (state: B08State) => void) { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); } private emit(patch: Partial) { @@ -45,7 +45,7 @@ export class B08Store { } async load() { - this.emit({ loading: true, message: "B08 DB ?묒뾽怨듦컙??遺덈윭?ㅻ뒗 以묒엯?덈떎." }); + this.emit({ loading: true, message: "B08 DB 작업공간을 불러오는 중입니다." }); const projectId = this.state.projectId; const version = this.state.basisVersion; const settled = await Promise.allSettled([ @@ -54,6 +54,7 @@ export class B08Store { B08Api.costing(projectId), B08Api.quantities(projectId), B08Api.runs(projectId), + B08Api.latestCalculation(projectId), ]); const data = empty(); if (settled[0].status === "fulfilled") data.basis = settled[0].value; @@ -61,30 +62,31 @@ export class B08Store { if (settled[2].status === "fulfilled") data.costing = settled[2].value; if (settled[3].status === "fulfilled") data.quantities = settled[3].value; if (settled[4].status === "fulfilled") data.runs = settled[4].value; + if (settled[5].status === "fulfilled") data.latest = settled[5].value; const failed = settled.filter((result) => result.status === "rejected").length; this.emit({ loading: false, data, message: failed - ? `DB ?곌껐 ?먮뒗 珥덇린 ?곗씠?곌? ?녿뒗 ?곸뿭 ${failed}媛쒓? ?덉뒿?덈떎. 媛믪쓣 ?낅젰???쒖옉?섏꽭??` - : "紐⑤뱺 B08 ?곗씠?곕? 遺덈윭?붿뒿?덈떎.", + ? `DB 연결 또는 초기 데이터가 없는 영역 ${failed}개가 있습니다. 값을 입력해 시작하세요.` + : "모든 B08 데이터를 불러왔습니다.", }); } async calculate() { const data = this.state.data; const basis = data.basis; - if (!basis) throw new Error("湲곗??뺣낫瑜?癒쇱? ?€?ν븯?몄슂."); + if (!basis) throw new Error("기준정보를 먼저 저장하세요."); const confirmation = data.quantities.confirmation; - if (!confirmation) throw new Error("?ㅺ퀎?섎웾??癒쇱? ?뺤젙?섏꽭??"); + if (!confirmation) throw new Error("설계수량을 먼저 확정하세요."); const unconfirmed = data.quantities.quantities.filter( (item: any) => item.status !== "CONFIRMED", ); - if (unconfirmed.length) throw new Error(`?ㅺ퀎?섎웾 誘명솗??${unconfirmed.length}嫄?); + if (unconfirmed.length) throw new Error(`설계수량 미확정 ${unconfirmed.length}건`); const priceVersion = data.quantities.quantities.find( (item: any) => item.price_version, )?.price_version; - if (!priceVersion) throw new Error("?뺤젙??媛€寃⑺뙋???놁뒿?덈떎."); + if (!priceVersion) throw new Error("확정된 가격판이 없습니다."); const result = await B08Api.calculate(this.state.projectId, { versions: { basis_version: basis.version, @@ -92,10 +94,8 @@ export class B08Store { quantity_version: confirmation.quantity_version, rule_version: basis.version, }, - inputs, - policies: basis.rate_policies, }); const nextData = { ...this.state.data, latest: result }; - this.emit({ data: nextData, message: "理쒖쥌怨듭궗鍮?怨꾩궛 ?ㅽ뻾???€?ν뻽?듬땲??" }); + this.emit({ data: nextData, message: "최종공사비 계산 실행을 저장했습니다." }); } } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts index 35963eee..bf13415e 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts @@ -18,7 +18,7 @@ export interface QuantityWorkspace { export interface WorkspaceData { basis: any | null; - catalog: any[]; + catalog: { sources: any[]; price_books: any[]; items: any[]; candidates: any[] }; costing: { unit_costs: any[]; equipment_rates: any[]; cost_basis: any[] }; quantities: QuantityWorkspace; runs: any[]; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts new file mode 100644 index 00000000..39aebd13 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts @@ -0,0 +1,55 @@ +import { TabContext } from "./B08_wf5_Quantity_Types"; + +export const referenceOptions = ( + ctx: TabContext, + allowed: Array<"CATALOG" | "UNIT_COST" | "EQUIPMENT" | "COST_BASIS">, +): Array<[string, string]> => { + const rows: Array<[string, string]> = []; + if (allowed.includes("CATALOG")) { + ctx.state.data.catalog.items + .filter((item: any) => item.applied_price != null) + .forEach((item: any) => + rows.push([ + `CATALOG|${item.id}|${item.item_name}`, + `[기초단가] ${item.item_code} ${item.item_name} / ${item.cost_type}`, + ]), + ); + } + if (allowed.includes("UNIT_COST")) { + ctx.state.data.costing.unit_costs + .filter((item: any) => item.status === "CONFIRMED") + .forEach((item: any) => + rows.push([ + `UNIT_COST|${item.id}|${item.name}`, + `[일위대가] ${item.unit_cost_code} ${item.name}`, + ]), + ); + } + if (allowed.includes("EQUIPMENT")) { + ctx.state.data.costing.equipment_rates + .filter((item: any) => item.status === "CONFIRMED") + .forEach((item: any) => + rows.push([ + `EQUIPMENT|${item.id}|${item.name}`, + `[중기사용료] ${item.equipment_code} ${item.name}`, + ]), + ); + } + if (allowed.includes("COST_BASIS")) { + ctx.state.data.costing.cost_basis + .filter((item: any) => item.status === "CONFIRMED") + .forEach((item: any) => + rows.push([ + `COST_BASIS|${item.id}|${item.name}`, + `[산출근거] ${item.basis_code} ${item.name}`, + ]), + ); + } + return rows; +}; + +export const parseReference = (value: unknown) => { + const [component_type, reference_id, reference_name] = String(value ?? "").split("|"); + if (!reference_id) throw new Error("확정된 DB 단가 참조를 선택하세요."); + return { component_type, reference_id, reference_name }; +}; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts index 414aa7b5..00bae86b 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts @@ -3,23 +3,27 @@ import { el, formData, input, money, section, select, table } from "./B08_wf5_Qu import { TabContext } from "./B08_wf5_Quantity_Types"; export function renderCatalog(ctx: TabContext) { + const catalog = ctx.state.data.catalog; const root = section( "재료·노무·장비·경비 기초단가", - "출처별 후보단가 중 승인한 적용단가만 후속 계산에 사용합니다.", + "가격판, 품목, 출처별 후보가격과 승인 적용단가를 DB에서 관리합니다.", ); - root.append(priceBookForm(ctx), itemForm(ctx), candidateForm(ctx), appliedPriceForm(ctx)); root.append( + priceBookForm(ctx), + itemForm(ctx), + candidateForm(ctx), + appliedPriceForm(ctx), table( - ["유형", "코드", "명칭", "규격", "단위", "비용", "적용단가", "선택근거"], - ctx.state.data.catalog.map((x: any) => [ - x.item_type, - x.item_code, - x.item_name, - x.specification, - x.unit, - x.cost_type, - x.applied_price == null ? "미선택" : money(Number(x.applied_price)), - x.selection_reason, + ["유형", "코드", "명칭", "규격", "단위", "비용분류", "적용단가", "가격판"], + catalog.items.map((item: any) => [ + item.item_type, + item.item_code, + item.item_name, + item.specification, + item.unit, + item.cost_type, + item.applied_price == null ? "미승인" : money(Number(item.applied_price)), + item.price_version, ]), ), ); @@ -27,32 +31,30 @@ export function renderCatalog(ctx: TabContext) { } function priceBookForm(ctx: TabContext) { - const box = section("단가표 버전", "기초단가는 반드시 특정 기준정보와 단가표 버전에 속합니다."); const form = el("form", "b08-inline-form") as HTMLFormElement; form.append( - input("price_version", "단가 버전"), - input("basis_version", "기준 버전", "text", ctx.state.basisVersion), - input("name", "단가표명"), + input("price_version", "가격판 버전"), + input("basis_version", "기준정보 버전", "text", ctx.state.basisVersion), + input("name", "가격판명"), input("effective_date", "적용일", "date"), select("status", "상태", [ ["DRAFT", "작성중"], ["CONFIRMED", "확정"], ]), ); - const button = el("button", "b08-button", "단가표 저장") as HTMLButtonElement; + const button = el("button", "b08-button", "가격판 저장") as HTMLButtonElement; button.type = "submit"; form.append(button); form.onsubmit = async (event) => { event.preventDefault(); await B08Api.savePriceBook(ctx.state.projectId, formData(form)); - ctx.message("단가표 버전을 저장했습니다."); + ctx.message("가격판을 저장했습니다."); + await ctx.refresh(); }; - box.append(form); - return box; + return form; } function itemForm(ctx: TabContext) { - const box = section("품목 마스터", "재료·노무·장비·경비의 코드·규격·단위를 등록합니다."); const form = el("form", "b08-form-grid") as HTMLFormElement; form.append( select("item_type", "품목 유형", [ @@ -82,77 +84,111 @@ function itemForm(ctx: TabContext) { form.onsubmit = async (event) => { event.preventDefault(); await B08Api.saveItem(ctx.state.projectId, formData(form)); + ctx.message("기초단가 품목을 저장했습니다."); await ctx.refresh(); }; - box.append(form); - return box; + return form; } function candidateForm(ctx: TabContext) { + const catalog = ctx.state.data.catalog; const box = section( - "출처별 후보단가", - "원단가·통화·환율·원화 환산단가와 근거 페이지를 저장합니다.", + "출처별 후보가격", + "품목과 가격출처를 선택하면 원화 환산가는 서버가 계산합니다.", ); const form = el("form", "b08-inline-form") as HTMLFormElement; form.append( - input("price_version", "단가 버전"), - input("item_id", "품목 ID"), - input("source_id", "출처 ID", "number"), + select( + "price_version", + "가격판", + catalog.price_books.map((book: any) => [ + book.price_version, + `${book.name} (${book.price_version})`, + ]), + ), + select( + "item_id", + "품목", + catalog.items.map((item: any) => [item.id, `${item.item_code} ${item.item_name}`]), + ), + select( + "source_id", + "가격출처", + catalog.sources.map((source: any) => [String(source.id), source.source_name]), + ), input("source_price", "원단가", "number"), input("currency", "통화", "text", "KRW"), input("exchange_rate", "환율", "number", "1"), - input("converted_price", "원화단가", "number"), input("reference_page", "근거 페이지"), input("valid_from", "적용일", "date"), ); - const button = el("button", "b08-button", "후보단가 저장") as HTMLButtonElement; + const button = el("button", "b08-button", "후보가격 저장") as HTMLButtonElement; button.type = "submit"; form.append(button); form.onsubmit = async (event) => { event.preventDefault(); - const x = formData(form); - const result: any = await B08Api.addCandidate(ctx.state.projectId, String(x.price_version), { - ...x, - source_id: Number(x.source_id), - source_price: Number(x.source_price), - exchange_rate: Number(x.exchange_rate), - converted_price: Number(x.converted_price), + const value = formData(form); + const sourcePrice = Number(value.source_price); + const exchangeRate = Number(value.exchange_rate); + await B08Api.addCandidate(ctx.state.projectId, String(value.price_version), { + ...value, + source_id: Number(value.source_id), + source_price: sourcePrice, + exchange_rate: exchangeRate, + converted_price: sourcePrice * exchangeRate, valid_to: null, }); - ctx.message(`후보단가 ID ${result.id} 저장 완료`); + ctx.message("후보가격을 저장했습니다."); + await ctx.refresh(); }; box.append(form); return box; } function appliedPriceForm(ctx: TabContext) { + const catalog = ctx.state.data.catalog; const box = section( "적용단가 승인", - "후보단가 ID와 선택 사유를 지정해야 구성원가에 사용할 수 있습니다.", + "후보가격을 선택하고 실제 적용단가와 선택 근거를 승인합니다.", ); const form = el("form", "b08-inline-form") as HTMLFormElement; form.append( - input("price_version", "단가 버전"), - input("item_id", "품목 ID"), - input("price_entry_id", "후보단가 ID", "number"), + select( + "candidate", + "후보가격", + catalog.candidates.map((candidate: any) => [ + `${candidate.price_version}|${candidate.item_id}|${candidate.id}|${candidate.converted_price}`, + `${candidate.price_version} · ${candidate.item_code} · ${candidate.source_name} · ${money(Number(candidate.converted_price))}`, + ]), + ), input("applied_price", "적용단가", "number"), - input("selection_reason", "선택 사유"), + input("selection_reason", "선택 근거"), ); const button = el( "button", "b08-button b08-button--primary", - "적용단가 저장", + "적용단가 승인", ) as HTMLButtonElement; button.type = "submit"; form.append(button); + const candidate = form.elements.namedItem("candidate") as HTMLSelectElement; + candidate.onchange = () => { + const parts = candidate.value.split("|"); + (form.elements.namedItem("applied_price") as HTMLInputElement).value = parts[3] ?? ""; + }; + candidate.dispatchEvent(new Event("change")); form.onsubmit = async (event) => { event.preventDefault(); - const x = formData(form); - await B08Api.applyPrice(ctx.state.projectId, String(x.price_version), { - ...x, - price_entry_id: Number(x.price_entry_id), - applied_price: Number(x.applied_price), + const value = formData(form); + const [version, itemId, entryId] = String(value.candidate).split("|"); + if (!entryId) throw new Error("승인할 후보가격을 먼저 등록하세요."); + await B08Api.applyPrice(ctx.state.projectId, version, { + item_id: itemId, + price_entry_id: Number(entryId), + applied_price: Number(value.applied_price), + selection_reason: value.selection_reason, }); + ctx.message("적용단가를 승인했습니다. 종속 원가는 재계산 대상으로 전환됩니다."); await ctx.refresh(); }; box.append(form); diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts index cc674a00..2985545e 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts @@ -1,16 +1,18 @@ import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; import { TabContext } from "./B08_wf5_Quantity_Types"; + export function renderCostBasis(ctx: TabContext) { const root = section( "단가산출근거", - "기초단가·일위대가·중기사용료와 수량식을 구조적으로 연결합니다.", + "확정 기초단가·일위대가·중기사용료와 수량식을 구조적으로 연결합니다.", ); - const variables: any[] = [], - components: any[] = []; - const f = el("form", "b08-form-grid") as HTMLFormElement; - f.append( - input("code", "산근 코드"), + const variables: any[] = []; + const components: any[] = []; + const form = el("form", "b08-form-grid") as HTMLFormElement; + form.append( + input("code", "산출근거 코드"), input("name", "명칭"), input("specification", "규격"), input("unit", "단위"), @@ -20,83 +22,109 @@ export function renderCostBasis(ctx: TabContext) { ["CONFIRMED", "확정"], ]), ); - const vf = el("form", "b08-inline-form") as HTMLFormElement; - vf.append( - input("code", "변수 코드"), - input("label", "변수명"), - input("value", "값", "number"), - input("unit", "단위"), - ); - const va = el("button", "b08-button", "변수 추가") as HTMLButtonElement; - va.type = "submit"; - vf.append(va); - const status = el("p", "", "변수 0건 · 구성 0건"); - vf.onsubmit = (e) => { - e.preventDefault(); - const x = formData(vf); - variables.push({ ...x, value: x.value === "" ? null : Number(x.value), required: true }); - status.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; - }; - const cf = el("form", "b08-inline-form") as HTMLFormElement; - cf.append( - select("component_type", "유형", [ - ["CATALOG", "기초단가"], - ["UNIT_COST", "일위대가"], - ["EQUIPMENT", "중기"], - ["COST_BASIS", "산근"], - ]), - input("reference_id", "참조 ID"), - input("reference_name", "참조명"), - input("quantity_expression", "수량식"), - select("cost_type", "비용", [ - ["LABOR", "노무"], - ["MATERIAL", "재료"], - ["EXPENSE", "경비"], - ]), - ); - const ca = el("button", "b08-button", "구성 추가") as HTMLButtonElement; - ca.type = "submit"; - cf.append(ca); - cf.onsubmit = (e) => { - e.preventDefault(); - const x = formData(cf); - components.push({ ...x, unit_price: 0, sort_order: components.length }); - status.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; - }; + const state = el("p", "", "변수 0건 · 구성 0건"); + const variableForm = createVariableForm(variables, components, state); + const componentForm = createComponentForm(ctx, variables, components, state); const save = el( "button", "b08-button b08-button--primary", - "산출근거 저장·계산", + "산출근거 계산·저장", ) as HTMLButtonElement; save.type = "submit"; - f.append(save); - f.onsubmit = async (e) => { - e.preventDefault(); + form.append(save); + form.onsubmit = async (event) => { + event.preventDefault(); + if (!components.length) throw new Error("산출근거 구성요소를 추가하세요."); + const value = formData(form); const result = await B08Api.saveCostBasis(ctx.state.projectId, { - ...formData(f), - status: x.status, + ...value, version_no: 1, variables, components, }); - ctx.message(`단가산출근거 계산 완료: ${result.total.toLocaleString()}원`); + ctx.message(`단가산출근거 저장 완료: ${money(result.total)}`); await ctx.refresh(); }; root.append( - f, - vf, - cf, - status, + form, + variableForm, + componentForm, + state, table( - ["코드", "명칭", "규격", "단위", "상태"], - ctx.state.data.costing.cost_basis.map((x: any) => [ - x.basis_code, - x.name, - x.specification, - x.unit, - x.status, + ["코드", "명칭", "단위", "노무비", "재료비", "경비", "합계", "상태"], + ctx.state.data.costing.cost_basis.map((item: any) => [ + item.basis_code, + item.name, + item.unit, + money(Number(item.labor_price)), + money(Number(item.material_price)), + money(Number(item.expense_price)), + money(Number(item.total_price)), + item.status, ]), ), ); return root; } + +function createVariableForm(variables: any[], components: any[], state: HTMLElement) { + const form = el("form", "b08-inline-form") as HTMLFormElement; + form.append( + input("code", "변수 코드"), + input("label", "변수명"), + input("value", "값", "number"), + input("unit", "단위"), + ); + const add = el("button", "b08-button", "변수 추가") as HTMLButtonElement; + add.type = "submit"; + form.append(add); + form.onsubmit = (event) => { + event.preventDefault(); + const value = formData(form); + variables.push({ + ...value, + value: value.value === "" ? null : Number(value.value), + required: true, + }); + state.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; + }; + return form; +} + +function createComponentForm( + ctx: TabContext, + variables: any[], + components: any[], + state: HTMLElement, +) { + const form = el("form", "b08-inline-form") as HTMLFormElement; + form.append( + select( + "reference", + "확정 단가", + referenceOptions(ctx, ["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"]), + ), + input("quantity_expression", "수량식"), + select("cost_type", "비용분류", [ + ["LABOR", "노무비"], + ["MATERIAL", "재료비"], + ["EXPENSE", "경비"], + ]), + ); + const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; + add.type = "submit"; + form.append(add); + form.onsubmit = (event) => { + event.preventDefault(); + const value = formData(form); + components.push({ + ...parseReference(value.reference), + quantity_expression: value.quantity_expression, + unit_price: 0, + cost_type: value.cost_type, + sort_order: components.length, + }); + state.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; + }; + return form; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts index 4d0b4f65..e70cf263 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts @@ -1,14 +1,16 @@ import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; import { TabContext } from "./B08_wf5_Quantity_Types"; + export function renderEquipment(ctx: TabContext) { const root = section( "중기사용료", - "기계가격·가동시간과 손료·연료·운전원·정비비를 시간당 비용으로 관리합니다.", + "장비가격·연간가동시간과 확정 기초단가 구성요소로 시간당 노무비·재료비·경비를 계산합니다.", ); const components: any[] = []; - const f = el("form", "b08-form-grid") as HTMLFormElement; - f.append( + const form = el("form", "b08-form-grid") as HTMLFormElement; + form.append( input("code", "장비 코드"), input("name", "장비명"), input("specification", "규격"), @@ -20,29 +22,28 @@ export function renderEquipment(ctx: TabContext) { ["CONFIRMED", "확정"], ]), ); - const cf = el("form", "b08-inline-form") as HTMLFormElement; - cf.append( - input("reference_id", "품목 ID"), - input("reference_name", "구성 코드"), - input("quantity", "시간당 수량", "number"), - select("cost_type", "비용", [ - ["LABOR", "노무"], - ["MATERIAL", "재료"], + const componentForm = el("form", "b08-inline-form") as HTMLFormElement; + componentForm.append( + select("reference", "기초단가", referenceOptions(ctx, ["CATALOG"])), + input("quantity", "시간당 투입량", "number"), + select("cost_type", "비용분류", [ + ["LABOR", "노무비"], + ["MATERIAL", "재료비"], ["EXPENSE", "경비"], ]), ); + const count = el("p", "", "구성 0건"); const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; add.type = "submit"; - cf.append(add); - const count = el("p", "", "구성 0건"); - cf.onsubmit = (e) => { - e.preventDefault(); - const x = formData(cf); + componentForm.append(add); + componentForm.onsubmit = (event) => { + event.preventDefault(); + const value = formData(componentForm); components.push({ - ...x, - component_type: "CATALOG", - quantity: Number(x.quantity), + ...parseReference(value.reference), + quantity: Number(value.quantity), unit_price: 0, + cost_type: value.cost_type, sort_order: components.length, }); count.textContent = `구성 ${components.length}건`; @@ -50,37 +51,39 @@ export function renderEquipment(ctx: TabContext) { const save = el( "button", "b08-button b08-button--primary", - "중기사용료 저장·계산", + "중기사용료 계산·저장", ) as HTMLButtonElement; save.type = "submit"; - f.append(save); - f.onsubmit = async (e) => { - e.preventDefault(); - const x = formData(f); + form.append(save); + form.onsubmit = async (event) => { + event.preventDefault(); + if (!components.length) throw new Error("장비 구성요소를 추가하세요."); + const value = formData(form); const result = await B08Api.saveEquipment(ctx.state.projectId, { - ...x, - equipment_price: Number(x.equipment_price), - annual_hours: Number(x.annual_hours), - status: x.status, + ...value, + equipment_price: Number(value.equipment_price), + annual_hours: Number(value.annual_hours), version_no: 1, components, }); - ctx.message(`중기사용료 계산 완료: ${result.total.toLocaleString()}원`); + ctx.message(`중기사용료 저장 완료: ${money(result.total)}`); await ctx.refresh(); }; root.append( - f, - cf, + form, + componentForm, count, table( - ["코드", "장비", "규격", "기계가격", "가동시간", "상태"], - ctx.state.data.costing.equipment_rates.map((x: any) => [ - x.equipment_code, - x.name, - x.specification, - x.equipment_price, - x.annual_hours, - x.status, + ["코드", "장비", "단위", "노무비", "재료비", "경비", "합계", "상태"], + ctx.state.data.costing.equipment_rates.map((item: any) => [ + item.equipment_code, + item.name, + item.unit, + money(Number(item.labor_price)), + money(Number(item.material_price)), + money(Number(item.expense_price)), + money(Number(item.total_price)), + item.status, ]), ), ); diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts index 1ffd9667..23d56974 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts @@ -31,7 +31,7 @@ export function renderQuantity(ctx: TabContext) { }; const references: Array<[string, string]> = []; - ctx.state.data.catalog + ctx.state.data.catalog.items .filter((x: any) => x.applied_price != null) .forEach((x: any) => references.push([`CATALOG|${x.id}`, `[기초] ${x.item_code} ${x.item_name}`]), diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts index 50867452..e00c076c 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts @@ -1,11 +1,16 @@ import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; import { TabContext } from "./B08_wf5_Quantity_Types"; + export function renderUnitCost(ctx: TabContext) { - const root = section("일위대가", "재료·노무·장비·경비의 투입계수로 단위당 비용을 계산합니다."); + const root = section( + "일위대가", + "확정된 기초단가·중기사용료·하위 일위대가와 투입계수로 단위당 원가를 계산하고 DB에 저장합니다.", + ); const components: any[] = []; - const f = el("form", "b08-form-grid") as HTMLFormElement; - f.append( + const form = el("form", "b08-form-grid") as HTMLFormElement; + form.append( input("code", "일위대가 코드"), input("name", "명칭"), input("specification", "규격"), @@ -21,76 +26,78 @@ export function renderUnitCost(ctx: TabContext) { ["CONFIRMED", "확정"], ]), ); - const cf = componentForm(components, root); - const btn = el( + const componentForm = createComponentForm(ctx, components); + const save = el( "button", "b08-button b08-button--primary", - "일위대가 저장·계산", + "일위대가 계산·저장", ) as HTMLButtonElement; - btn.type = "submit"; - f.append(btn); - f.onsubmit = async (e) => { - e.preventDefault(); + save.type = "submit"; + form.append(save); + form.onsubmit = async (event) => { + event.preventDefault(); if (!components.length) throw new Error("구성요소를 추가하세요."); - const x = formData(f); + const value = formData(form); const result = await B08Api.saveUnitCost(ctx.state.projectId, { - ...x, - rounding_unit: Number(x.rounding_unit), - status: x.status, + ...value, + rounding_unit: Number(value.rounding_unit), version_no: 1, components, }); - ctx.message(`일위대가 계산 완료: ${result.total.toLocaleString()}원`); + ctx.message(`일위대가 저장 완료: ${money(result.total)}`); await ctx.refresh(); }; root.append( - f, - cf, + form, + componentForm, table( - ["코드", "명칭", "규격", "단위", "상태"], - ctx.state.data.costing.unit_costs.map((x: any) => [ - x.unit_cost_code, - x.name, - x.specification, - x.unit, - x.status, + ["코드", "명칭", "단위", "노무비", "재료비", "경비", "합계", "상태"], + ctx.state.data.costing.unit_costs.map((item: any) => [ + item.unit_cost_code, + item.name, + item.unit, + money(Number(item.labor_price)), + money(Number(item.material_price)), + money(Number(item.expense_price)), + money(Number(item.total_price)), + item.status, ]), ), ); return root; } -function componentForm(rows: any[], root: HTMLElement) { - const box = section("구성요소", "적용단가와 투입계수를 입력합니다."); - const f = el("form", "b08-inline-form") as HTMLFormElement; - f.append( - select("component_type", "유형", [ - ["CATALOG", "기초단가"], - ["EQUIPMENT", "중기"], - ["UNIT_COST", "일위대가"], - ]), - input("reference_id", "참조 ID"), - input("reference_name", "참조명"), + +function createComponentForm(ctx: TabContext, rows: any[]) { + const box = section( + "구성요소", + "DB에서 확정된 단가를 선택하고 투입계수와 비용분류를 지정합니다.", + ); + const form = el("form", "b08-inline-form") as HTMLFormElement; + form.append( + select("reference", "확정 단가", referenceOptions(ctx, ["CATALOG", "EQUIPMENT", "UNIT_COST"])), input("quantity", "투입계수", "number"), - select("cost_type", "비용", [ - ["LABOR", "노무"], - ["MATERIAL", "재료"], + select("cost_type", "비용분류", [ + ["LABOR", "노무비"], + ["MATERIAL", "재료비"], ["EXPENSE", "경비"], ]), ); - const b = el("button", "b08-button", "구성 추가") as HTMLButtonElement; - b.type = "submit"; - f.append(b); - f.onsubmit = (e) => { - e.preventDefault(); - const x = formData(f); + const count = el("p", "b08-component-count", "구성 0건"); + const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; + add.type = "submit"; + form.append(add); + form.onsubmit = (event) => { + event.preventDefault(); + const value = formData(form); rows.push({ - ...x, - quantity: Number(x.quantity), + ...parseReference(value.reference), + quantity: Number(value.quantity), unit_price: 0, + cost_type: value.cost_type, sort_order: rows.length, }); - root.querySelector(".b08-component-count")!.textContent = `구성 ${rows.length}건`; + count.textContent = `구성 ${rows.length}건`; }; - box.append(f, el("p", "b08-component-count", "구성 0건")); + box.append(form, count); return box; }