260725_7
This commit is contained in:
@@ -30,16 +30,28 @@ export interface DesignDrawingListResponse {
|
||||
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
|
||||
export type QuantityTable = Record<string, number | null>;
|
||||
|
||||
/** 측구 형식별 규격 (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;
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string>): Record<string, number | null> {
|
||||
const table: Record<string, number | null> = {};
|
||||
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<string, string>;
|
||||
onEdit: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
const ValueInput: FC<ValueInputProps> = ({ fieldKey, colSpan, values, onEdit }) => (
|
||||
<td className="cad-qtable__value" colSpan={colSpan}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={values[fieldKey] ?? ''}
|
||||
onChange={(event) => onEdit(fieldKey, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
const DerivedCell: FC<{ value: string }> = ({ value }) => (
|
||||
<td className="cad-qtable__value cad-qtable__value--derived" colSpan={1}>
|
||||
<input type="text" readOnly value={value} tabIndex={-1} />
|
||||
</td>
|
||||
);
|
||||
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<Record<string, string>>({});
|
||||
const identityRef = useRef<string>('');
|
||||
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<section className="cad-qtable controls" data-collapsed={meta.kind !== 'cross' || collapsed}>
|
||||
<section className="cad-qtable controls" data-collapsed="true">
|
||||
<header className="cad-qtable__header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -155,117 +51,7 @@ export const QuantityPanel: FC = () => {
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{meta.kind === 'cross' && (
|
||||
<button
|
||||
type="button"
|
||||
className="cad-qtable__collapse"
|
||||
title={collapsed ? '표 펼치기' : '표 접기'}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
{collapsed ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 표는 종단도에서도 DOM에 남겨 패널 폭을 통일한다 (CSS로만 숨김) */}
|
||||
<div className="cad-qtable__body">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
지반고
|
||||
</th>
|
||||
<ValueInput fieldKey="ground" colSpan={1} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
계획고
|
||||
</th>
|
||||
<ValueInput fieldKey="planned" colSpan={1} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
절토고
|
||||
</th>
|
||||
<DerivedCell value={cutText} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
성토고
|
||||
</th>
|
||||
<DerivedCell value={fillText} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
|
||||
흙깎기
|
||||
</th>
|
||||
<th className="cad-qtable__label">토사</th>
|
||||
<ValueInput fieldKey="cut_soil" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
지장목제거
|
||||
</th>
|
||||
<ValueInput fieldKey="tree_removal" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
|
||||
옆도랑파기
|
||||
</th>
|
||||
<th className="cad-qtable__label">토사</th>
|
||||
<ValueInput fieldKey="ditch_soil" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="cad-qtable__label">연암</th>
|
||||
<ValueInput
|
||||
fieldKey="cut_soft_rock"
|
||||
colSpan={2}
|
||||
values={values}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={2}>
|
||||
비탈보호공
|
||||
</th>
|
||||
<th className="cad-qtable__label">성토면</th>
|
||||
<ValueInput
|
||||
fieldKey="fill_slope_protection"
|
||||
colSpan={2}
|
||||
values={values}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
<th className="cad-qtable__label">연암</th>
|
||||
<ValueInput
|
||||
fieldKey="ditch_soft_rock"
|
||||
colSpan={2}
|
||||
values={values}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="cad-qtable__label">보통암</th>
|
||||
<ValueInput fieldKey="cut_rock" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label">절토면</th>
|
||||
<ValueInput
|
||||
fieldKey="cut_slope_protection"
|
||||
colSpan={2}
|
||||
values={values}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
<th className="cad-qtable__label">보통암</th>
|
||||
<ValueInput fieldKey="ditch_rock" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
흙쌓기
|
||||
</th>
|
||||
<ValueInput fieldKey="embankment" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
제근
|
||||
</th>
|
||||
<ValueInput fieldKey="grubbing" colSpan={2} values={values} onEdit={handleEdit} />
|
||||
<th className="cad-qtable__label" colSpan={2}>
|
||||
노면고르기
|
||||
</th>
|
||||
<ValueInput
|
||||
fieldKey="surface_grading"
|
||||
colSpan={2}
|
||||
values={values}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const B08Api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
catalog: (p: string) => req<any[]>(path(p, "/catalog")),
|
||||
catalog: (p: string) => req<any>(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<any[]>(path(p, "/calculation-runs")),
|
||||
latestCalculation: (p: string) => req<any | null>(path(p, "/calculation-runs/latest")),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
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,
|
||||
)
|
||||
@@ -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()
|
||||
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,),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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
|
||||
@@ -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<B08State>) {
|
||||
@@ -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: "최종공사비 계산 실행을 저장했습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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}`]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user