Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_4
145-1 · 145-3 · 145-4 · 145-5 받음 · 충돌 = B09 이음 자리 넷 — Router · Shell 짝 셈 한 벌(바탕 → 산근 → 대가 → 내역 → 총괄) · used_keys 는 사본 셈 길(참조사본 포함) · 144 · 145-1 시험 단언 이음에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
@@ -175,6 +175,24 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
|
||||
);
|
||||
}
|
||||
|
||||
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */
|
||||
export interface CrossLayout {
|
||||
default: [number, number];
|
||||
sheets: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
export function fetchCrossLayout(projectId: string): Promise<CrossLayout> {
|
||||
return requestJson(`/projects/${projectId}/cross-layout`);
|
||||
}
|
||||
|
||||
/** 배치 저장 — 담긴 측점이 바뀐 확정 장은 서버가 풂. */
|
||||
export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise<CrossLayout> {
|
||||
return requestJson(`/projects/${projectId}/cross-layout`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(layout),
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
|
||||
export interface PlanKind {
|
||||
id: string;
|
||||
@@ -233,6 +251,14 @@ export function updatePlanKind(
|
||||
});
|
||||
}
|
||||
|
||||
/** 종류 차례 — 끌어 놓은 차례 그대로(140-2 · 놓는 즉시 · 번호 바뀐 장은 서버가 확정 풂). */
|
||||
export function reorderPlanKinds(projectId: string, ids: string[]): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds-order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -159,7 +159,11 @@ def _diameter_label(props: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _hatch_entity(
|
||||
drawing_id: str, number: int, ring: list[tuple[float, float]], color: str
|
||||
drawing_id: str,
|
||||
number: int,
|
||||
ring: list[tuple[float, float]],
|
||||
color: str,
|
||||
layer: str = BASIN_LAYER_ID,
|
||||
) -> dict[str, Any]:
|
||||
"""유역 안쪽 격자 해칭. openwebcad Hatch 엔티티라 CAD에서 무늬·간격을 그대로 편집한다."""
|
||||
return {
|
||||
@@ -167,7 +171,7 @@ def _hatch_entity(
|
||||
"type": "Hatch",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": BASIN_LAYER_ID,
|
||||
"layerId": layer,
|
||||
"shapeData": {
|
||||
"points": [{"x": x, "y": y} for x, y in ring],
|
||||
"options": {
|
||||
@@ -181,7 +185,11 @@ def _hatch_entity(
|
||||
|
||||
|
||||
def _number_badge_entities(
|
||||
drawing_id: str, number: int, center: tuple[float, float], color: str
|
||||
drawing_id: str,
|
||||
number: int,
|
||||
center: tuple[float, float],
|
||||
color: str,
|
||||
layer: str = BASIN_LAYER_ID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""유역 번호를 동그라미 배지로 그린다 — 회색 원판 + 유역색 테두리 + 검은 숫자.
|
||||
|
||||
@@ -202,7 +210,7 @@ def _number_badge_entities(
|
||||
"type": "Hatch",
|
||||
"lineColor": _BADGE_FILL,
|
||||
"lineWidth": 1,
|
||||
"layerId": BASIN_LAYER_ID,
|
||||
"layerId": layer,
|
||||
"shapeData": {
|
||||
"points": [{"x": x, "y": y} for x, y in disc],
|
||||
"options": {"style": "solid", "color": _BADGE_FILL, "spacing": 1, "angle": 0},
|
||||
@@ -213,7 +221,7 @@ def _number_badge_entities(
|
||||
"type": "Circle",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": BASIN_LAYER_ID,
|
||||
"layerId": layer,
|
||||
"shapeData": {"center": {"x": cx, "y": cy}, "radius": _BADGE_RADIUS_MM},
|
||||
},
|
||||
_text_entity(
|
||||
@@ -221,7 +229,7 @@ def _number_badge_entities(
|
||||
str(number),
|
||||
cx,
|
||||
cy,
|
||||
BASIN_LAYER_ID,
|
||||
layer,
|
||||
_NUMBER_FONT_SIZE,
|
||||
_BADGE_TEXT_COLOR,
|
||||
),
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString
|
||||
from shapely.geometry import box as shapely_box
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
@@ -25,8 +28,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_layer,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
station_plus_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import route_frame
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
@@ -114,8 +119,8 @@ def plan_chunks(
|
||||
def fits(part: list[tuple[float, float, float]]) -> bool:
|
||||
width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part)
|
||||
height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part)
|
||||
# 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다.
|
||||
return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w)
|
||||
# 그림을 돌리지 않으므로 도곽 방향 그대로만 본다(141-4 — 돌린 맞춤은 그림칸을 넘쳤음)
|
||||
return width <= span_w and height <= span_h
|
||||
|
||||
chunks: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
@@ -134,6 +139,50 @@ def plan_chunks(
|
||||
return chunks
|
||||
|
||||
|
||||
MATCH_HALF_MM = 30.0 # 맞춤선 반길이(종이 mm · 노선 직각)
|
||||
|
||||
|
||||
def sheet_links(
|
||||
route: list[tuple[float, float, float]],
|
||||
chunk: dict[str, Any],
|
||||
total: int,
|
||||
box: tuple[float, float, float, float],
|
||||
interval_m: float,
|
||||
mm: float,
|
||||
neighbors: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""여러 장 이음(141-4) — 장 경계 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 이웃 구간 노선.
|
||||
|
||||
`route` = (누가거리, x, y) 노선 전체 · 맞춤선 = 경계 측점에서 노선 직각(좌측 법선) ·
|
||||
이웃 구간 = 이 장 구간 밖 노선을 도곽으로 자른 것(흐리게 그림 · `neighbors` 일 때만).
|
||||
"""
|
||||
start_m, end_m, number = float(chunk["start_m"]), float(chunk["end_m"]), int(chunk["number"])
|
||||
half = MATCH_HALF_MM / mm
|
||||
lines = []
|
||||
for at, other in ((start_m, number - 1), (end_m, number + 1)):
|
||||
if not 1 <= other <= total or len(route) < 2:
|
||||
continue
|
||||
x, y, nx, ny = route_frame(route, at)
|
||||
no = station_no_label(at, interval_m).replace("No.", "NO.")
|
||||
lines.append(
|
||||
{
|
||||
"points": [[x - nx * half, y - ny * half], [x + nx * half, y + ny * half]],
|
||||
"label": f"MATCH LINE {no} · {other}장 이어짐",
|
||||
}
|
||||
)
|
||||
out: list[list[list[float]]] = []
|
||||
if neighbors:
|
||||
frame = shapely_box(*box)
|
||||
for part in ([p for p in route if p[0] <= start_m], [p for p in route if p[0] >= end_m]):
|
||||
if len(part) < 2:
|
||||
continue
|
||||
clipped = LineString([(x, y) for _c, x, y in part]).intersection(frame)
|
||||
for piece in getattr(clipped, "geoms", [clipped]):
|
||||
if isinstance(piece, LineString) and len(piece.coords) >= 2:
|
||||
out.append([[round(x, 4), round(y, 4)] for x, y in piece.coords])
|
||||
return {"lines": lines, "neighbors": out}
|
||||
|
||||
|
||||
def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str:
|
||||
"""장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다."""
|
||||
return kind if total <= 1 else f"{kind}_{chunk['number']}"
|
||||
|
||||
@@ -20,6 +20,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import (
|
||||
_BADGE_RADIUS_MM,
|
||||
BASIN_COLORS,
|
||||
_hatch_entity,
|
||||
_number_badge_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
@@ -38,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
|
||||
label_entities,
|
||||
place_labels,
|
||||
text_rect,
|
||||
text_width,
|
||||
)
|
||||
|
||||
@@ -62,6 +69,9 @@ PIECES: tuple[tuple[str, str, bool], ...] = (
|
||||
("lidar", "라이다 음영", True),
|
||||
("contour", "등고선", True),
|
||||
("stream", "계류", True),
|
||||
# 배수유역(141-3) — 유역도 해치 · 번호 배지를 어느 평면도에나(장 범위로 자름)
|
||||
("basin", "배수유역 경계", False),
|
||||
("basin_no", "유역 번호", False),
|
||||
("slope_ticks", "비탈 눈금", False),
|
||||
("road_edge", "노견선", False),
|
||||
("fill_outline", "성토 외곽선", False),
|
||||
@@ -446,6 +456,21 @@ def _piece_entities(
|
||||
font=PARCEL_FONT,
|
||||
)
|
||||
)
|
||||
elif piece == "basin":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
ring = [paper(point) for point in item["ring"]]
|
||||
tint = BASIN_COLORS[(int(item["number"]) - 1) % len(BASIN_COLORS)]
|
||||
seed = f"{drawing_id}:plan:{index}"
|
||||
out.append(_hatch_entity(seed, int(item["number"]), ring, tint, layer))
|
||||
outline = polyline_entity(drawing_id, ring, layer, tint, suffix=f":{index}")
|
||||
if outline:
|
||||
out.append(outline)
|
||||
elif piece == "basin_no":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
number = int(item["number"])
|
||||
tint = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)]
|
||||
seed = f"{drawing_id}:plan:{index}"
|
||||
out.extend(_number_badge_entities(seed, number, paper(item["center"]), tint, layer))
|
||||
return out
|
||||
|
||||
|
||||
@@ -455,6 +480,59 @@ def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]:
|
||||
return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y)
|
||||
|
||||
|
||||
SHEET_LAYER = "b07-plan-sheet" # 여러 장 이음 — 맞춤선 · 이웃 구간 노선(141-4)
|
||||
_SHEET_COLOR = "#ff9d4d"
|
||||
_NEIGHBOR_COLOR = "#7d8590" # 이웃 구간 노선 — 흐리게
|
||||
|
||||
|
||||
def _sheet_link_entities(
|
||||
drawing_id: str, links: dict[str, Any], paper: Any
|
||||
) -> tuple[list[dict[str, Any]], list[tuple[float, float, float, float]]]:
|
||||
"""맞춤선(노선 직각 쇄선 + 「MATCH LINE …」) · 이웃 구간 노선(흐린 파선) · 글 사각 = 장애물."""
|
||||
out: list[dict[str, Any]] = []
|
||||
rects: list[tuple[float, float, float, float]] = []
|
||||
for index, points in enumerate(links.get("neighbors") or []):
|
||||
line = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(p) for p in points],
|
||||
SHEET_LAYER,
|
||||
_NEIGHBOR_COLOR,
|
||||
suffix=f":n:{index}",
|
||||
dash=[3, 3],
|
||||
)
|
||||
if line:
|
||||
out.append(line)
|
||||
size = _LABEL_FONT + 0.5
|
||||
for index, item in enumerate(links.get("lines") or []):
|
||||
a, b = (paper(point) for point in item["points"])
|
||||
line = polyline_entity(
|
||||
drawing_id, [a, b], SHEET_LAYER, _SHEET_COLOR, suffix=f":m:{index}", width=2,
|
||||
dash=[8, 3, 2, 3],
|
||||
) # fmt: skip
|
||||
if line:
|
||||
out.append(line)
|
||||
length = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0
|
||||
ux, uy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
|
||||
# 글은 맞춤선 끝 바깥으로 선 방향 — 거꾸로 서지 않게 오른쪽(위)쪽 끝에서
|
||||
end, (dx, dy) = (b, (ux, uy)) if ux > 1e-9 or (ux > -1e-9 and uy > 0) else (a, (-ux, -uy))
|
||||
x, y = end[0] + dx * 2.0, end[1] + dy * 2.0
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:sheet:{index}",
|
||||
item["label"],
|
||||
x,
|
||||
y,
|
||||
SHEET_LAYER,
|
||||
size,
|
||||
_SHEET_COLOR,
|
||||
align="left",
|
||||
direction=(dx, dy),
|
||||
)
|
||||
)
|
||||
rects.append(text_rect(item["label"], x, y, size, "left", (dx, dy)))
|
||||
return out, rects
|
||||
|
||||
|
||||
def build_plan_kind_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
@@ -486,6 +564,16 @@ def build_plan_kind_drawing(
|
||||
obstacles.append(
|
||||
(cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2)
|
||||
)
|
||||
elif piece == "basin_no": # 유역 번호 배지(141-3)
|
||||
radius = _BADGE_RADIUS_MM
|
||||
for item in pieces[piece].get("items") or []:
|
||||
cx, cy = paper(item["center"])
|
||||
obstacles.append((cx - radius, cy - radius, cx + radius, cy + radius))
|
||||
links = pieces.get("sheet_links")
|
||||
if links:
|
||||
made, rects = _sheet_link_entities(drawing_id, links, paper)
|
||||
entities.extend(made)
|
||||
obstacles += rects
|
||||
entities.extend(label_entities(drawing_id, place_labels(labels, obstacles)))
|
||||
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
|
||||
frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm)
|
||||
@@ -542,6 +630,7 @@ def build_plan_kind_drawing(
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
*layers,
|
||||
*([_layer(SHEET_LAYER, "맞춤선 · 이웃 구간")] if links else []),
|
||||
_layer("b07-plan-title", "표제"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다.
|
||||
"""B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4)에 단면을 하나씩 담는다(140-3).
|
||||
|
||||
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
|
||||
(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가
|
||||
정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이
|
||||
정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다.
|
||||
(2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은
|
||||
칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -95,111 +94,52 @@ def section_block_size(
|
||||
return (width, top + below)
|
||||
|
||||
|
||||
def _stack_columns(group: list[tuple[int | float, float, float]]) -> list[list[int]]:
|
||||
"""블록 순번을 열로 묶는다 — 아래에서 위로 쌓다 높이가 넘치면 오른쪽 새 열."""
|
||||
_usable_w, usable_h = usable_area()
|
||||
columns: list[list[int]] = []
|
||||
height = 0.0
|
||||
for index, (_c, _w, block_h) in enumerate(group):
|
||||
if not columns or height + block_h > usable_h + 1e-6:
|
||||
columns.append([])
|
||||
height = 0.0
|
||||
columns[-1].append(index)
|
||||
height += block_h
|
||||
return columns
|
||||
|
||||
|
||||
def _fits(group: list[tuple[int | float, float, float]]) -> bool:
|
||||
"""group 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다."""
|
||||
def grid_slots(n: int, cols: int, rows: int) -> list[list[float]]:
|
||||
"""n 개 칸 가운데 — 그림칸(usable_area)을 열 × 행으로 똑같이 나눔 · 좌하단 → 위 → 오른쪽 열."""
|
||||
usable_w, usable_h = usable_area()
|
||||
columns = _stack_columns(group)
|
||||
width = sum(max(group[i][1] for i in column) for column in columns)
|
||||
tallest = max(sum(group[i][2] for i in column) for column in columns)
|
||||
return width <= usable_w + 1e-6 and tallest <= usable_h + 1e-6
|
||||
cell_w, cell_h = usable_w / cols, usable_h / rows
|
||||
return [
|
||||
[
|
||||
-usable_w / 2.0 + (index // rows + 0.5) * cell_w,
|
||||
-usable_h / 2.0 + (index % rows + 0.5) * cell_h,
|
||||
]
|
||||
for index in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _max_take(blocks: list[tuple[int | float, float, float]], start: int) -> int:
|
||||
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수."""
|
||||
limit = 0
|
||||
for take in range(1, len(blocks) - start + 1):
|
||||
if not _fits(blocks[start : start + take]):
|
||||
break
|
||||
limit = take
|
||||
return limit
|
||||
def sheet_grid(layout: dict[str, Any] | None, number: int) -> tuple[int, int]:
|
||||
"""장 번호의 (열, 행) — 장마다 값이 없으면 기본(없으면 4 × 4)."""
|
||||
layout = layout or {}
|
||||
grid = (layout.get("sheets") or {}).get(str(number)) or layout.get("default") or (4, 4)
|
||||
return int(grid[0]), int(grid[1])
|
||||
|
||||
|
||||
def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]:
|
||||
"""장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지).
|
||||
def plan_cross_sheets(
|
||||
chainages: list[int | float], layout: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""측점 목록을 A1 장으로 나눈다 — 장마다 열 × 행 칸(기본 4 × 4 · 140-3).
|
||||
|
||||
앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로
|
||||
튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면
|
||||
크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수
|
||||
조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다.
|
||||
같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다).
|
||||
"""
|
||||
total = len(blocks)
|
||||
best_sheets = [0] * (total + 1)
|
||||
best_take = [0] * (total + 1)
|
||||
for start in range(total - 1, -1, -1):
|
||||
limit = max(_max_take(blocks, start), 1)
|
||||
choice = (total + 1, 0)
|
||||
for take in range(1, limit + 1):
|
||||
candidate = (best_sheets[start + take] + 1, -take)
|
||||
if candidate < choice:
|
||||
choice = candidate
|
||||
best_sheets[start], best_take[start] = choice[0], -choice[1]
|
||||
breaks: list[int] = []
|
||||
start = 0
|
||||
while start < total:
|
||||
breaks.append(best_take[start])
|
||||
start += best_take[start]
|
||||
return breaks
|
||||
|
||||
|
||||
def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]:
|
||||
"""블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열.
|
||||
|
||||
블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은
|
||||
단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고
|
||||
블록은 열 가운데에 선다.
|
||||
"""
|
||||
usable_w, usable_h = usable_area()
|
||||
slots: list[list[float]] = [[0.0, 0.0] for _ in group]
|
||||
left = -usable_w / 2.0
|
||||
for column in _stack_columns(group):
|
||||
column_w = max(group[i][1] for i in column)
|
||||
bottom = -usable_h / 2.0
|
||||
for i in column:
|
||||
slots[i] = [left + column_w / 2.0, bottom]
|
||||
bottom += group[i][2]
|
||||
left += column_w
|
||||
return slots
|
||||
|
||||
|
||||
def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
|
||||
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
|
||||
|
||||
블록은 자기 크기(그림 + 테두리 1 mm)로 열마다 쌓는다. 작성 척도는 1/100 고정 — 안
|
||||
들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에
|
||||
담기는 측점 수는 세트마다 다르다(2026-09-04 사용자 확정).
|
||||
단면 크기와 무관하게 칸 수만큼 담는다(큰 단면이 칸을 넘는 겹침은 사용자가 배치로 고침).
|
||||
작성 척도는 1/100 고정 — 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준).
|
||||
"""
|
||||
sheets: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
for count in _sheet_breaks(blocks):
|
||||
group = blocks[start : start + count]
|
||||
chainages = [chainage for chainage, _w, _h in group]
|
||||
while start < len(chainages):
|
||||
cols, rows = sheet_grid(layout, len(sheets) + 1)
|
||||
group = list(chainages[start : start + cols * rows])
|
||||
sheets.append(
|
||||
{
|
||||
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
|
||||
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 배치가
|
||||
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
|
||||
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
|
||||
"id": f"cross_s{cross_key(chainages[0])}m",
|
||||
"id": f"cross_s{cross_key(group[0])}m",
|
||||
"number": len(sheets) + 1,
|
||||
"chainages": chainages,
|
||||
"slots": _slots(group),
|
||||
"chainages": group,
|
||||
"grid": [cols, rows],
|
||||
"slots": grid_slots(len(group), cols, rows),
|
||||
}
|
||||
)
|
||||
start += count
|
||||
start += len(group)
|
||||
return sheets
|
||||
|
||||
|
||||
@@ -227,10 +167,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
if bbox is None:
|
||||
continue
|
||||
min_x, min_y, max_x, max_y = bbox
|
||||
center_x, bottom_y = slots[index]
|
||||
# 2) 가로는 칸 가운데, 세로는 칸 아래 변에 테두리 아래 변을 맞춰 다시 만든다.
|
||||
# 테두리는 자기 그림 + 1 mm — 칸 크기로 넓히지 않는다(68-1).
|
||||
origin = (center_x - (min_x + max_x) / 2.0, bottom_y - min_y)
|
||||
center_x, center_y = slots[index]
|
||||
# 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다.
|
||||
origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0)
|
||||
placed = build_cross_drawing(
|
||||
section["source"],
|
||||
seed_id,
|
||||
@@ -244,12 +183,13 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
entities.extend(placed["entities"])
|
||||
placements.extend(placed.get("cross_placements") or [])
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
if entities:
|
||||
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3).
|
||||
usable_w, usable_h = usable_area()
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
sheet["id"],
|
||||
bbox,
|
||||
(-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0),
|
||||
fit=False,
|
||||
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import UUID
|
||||
from aiomysql import DictCursor
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
@@ -45,6 +46,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_drawing,
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
read_cross_layout,
|
||||
write_cross_layout,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
|
||||
@@ -267,11 +270,7 @@ async def get_design_drawing_list(
|
||||
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
drawings = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
return DesignDrawingListResponse(
|
||||
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
|
||||
)
|
||||
@@ -394,18 +393,13 @@ async def confirm_design_drawing(
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
items = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
|
||||
if not item:
|
||||
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
|
||||
sheet = None
|
||||
if CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
plan = await asyncio.to_thread(
|
||||
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path)
|
||||
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
||||
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
|
||||
# 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}").
|
||||
@@ -498,14 +492,8 @@ async def invalidate_design_drawing(
|
||||
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
# 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로,
|
||||
# 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다
|
||||
# (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가).
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
items = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
# 장 나눔은 배치(열 × 행)만 따름 — 설계 · 덧그림 없이 만들어도 같은 장 id(140-3).
|
||||
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
if drawing_id not in {item.id for item in items}:
|
||||
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
|
||||
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
|
||||
@@ -515,9 +503,7 @@ async def invalidate_design_drawing(
|
||||
if cross_match:
|
||||
stale = [parse_cross_key(cross_match.group(1))]
|
||||
elif CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
plan = await asyncio.to_thread(
|
||||
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path)
|
||||
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
||||
stale = list(sheet["chainages"]) if sheet else []
|
||||
pool = get_db_pool()
|
||||
@@ -554,3 +540,52 @@ async def invalidate_design_drawing(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
class CrossLayoutRequest(BaseModel):
|
||||
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3)."""
|
||||
|
||||
default: list[int] = Field(default_factory=lambda: [4, 4])
|
||||
sheets: dict[str, list[int]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/{project_id}/cross-layout")
|
||||
async def get_cross_layout(project_id: UUID) -> Any:
|
||||
"""횡단 장 배치(파일 없으면 기본 4 × 4)."""
|
||||
_route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id)
|
||||
return await asyncio.to_thread(read_cross_layout, project_root)
|
||||
|
||||
|
||||
@router.put("/{project_id}/cross-layout")
|
||||
async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any:
|
||||
"""횡단 장 배치를 쓴다 — 담긴 측점이 바뀐 확정 장은 풀고 그 측점 설계를 잠정으로([수정] 꼴)."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id)
|
||||
stale = await asyncio.to_thread(
|
||||
write_cross_layout, project_root, longitudinal_path, request.model_dump()
|
||||
)
|
||||
if stale:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
for chainage in stale:
|
||||
await merge_cross_section_design_by_round(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage=chainage,
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return await asyncio.to_thread(read_cross_layout, project_root)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except PermissionError as exc:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -23,6 +23,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
reorder_kinds,
|
||||
update_kind,
|
||||
write_marks,
|
||||
)
|
||||
@@ -45,6 +46,10 @@ class PlanKindRequest(BaseModel):
|
||||
scale: int | None = None
|
||||
|
||||
|
||||
class PlanKindsOrderRequest(BaseModel):
|
||||
ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlanMark(BaseModel):
|
||||
chainage_m: float = Field(ge=0)
|
||||
refuge: bool = False
|
||||
@@ -102,6 +107,17 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
return _error(400, exc)
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan-kinds-order")
|
||||
async def put_plan_kinds_order(project_id: UUID, request: PlanKindsOrderRequest) -> Any:
|
||||
"""종류 차례 — 모달에서 줄을 끌어 놓은 차례 그대로(140-2 · 놓는 즉시)."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(reorder_kinds, root, request.ids)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any:
|
||||
"""종류 업데이트 — 이름 · 조각을 고친다(기본 넷도)."""
|
||||
|
||||
@@ -34,7 +34,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
|
||||
STANDARD_LABEL,
|
||||
@@ -82,6 +81,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
@@ -97,16 +97,67 @@ MASS_HAUL_ID = "mass_haul"
|
||||
COVER_ID = "cover"
|
||||
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
|
||||
CROSS_STANDARD_ID = "cross_standard"
|
||||
# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다.
|
||||
CROSS_SHEET_LAYOUT = 2
|
||||
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다.
|
||||
CROSS_SHEET_LAYOUT = 3
|
||||
# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3).
|
||||
CROSS_LAYOUT_FILE = "cross_layout.json"
|
||||
CROSS_GRID_DEFAULT = [4, 4]
|
||||
_CROSS_GRID_MAX = 10
|
||||
|
||||
|
||||
def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int | float, dict[str, Any]] | None = None,
|
||||
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
|
||||
) -> list[DesignDrawingItem]:
|
||||
def _cross_grid(value: Any) -> list[int]:
|
||||
if (
|
||||
not isinstance(value, (list, tuple))
|
||||
or len(value) != 2
|
||||
or not all(isinstance(n, int) and 1 <= n <= _CROSS_GRID_MAX for n in value)
|
||||
):
|
||||
raise ValueError(f"배치는 열 · 행 1~{_CROSS_GRID_MAX} 사이 정수로 적어 주세요.")
|
||||
return [int(value[0]), int(value[1])]
|
||||
|
||||
|
||||
def read_cross_layout(project_root: Path) -> dict[str, Any]:
|
||||
"""횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만."""
|
||||
path = _design_root(project_root) / CROSS_LAYOUT_FILE
|
||||
try:
|
||||
return checked_cross_layout(_read_json(path))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}}
|
||||
|
||||
|
||||
def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]:
|
||||
sheets = layout.get("sheets") or {}
|
||||
if not all(str(key).isdigit() and int(key) >= 1 for key in sheets):
|
||||
raise ValueError("장 번호가 올바르지 않습니다.")
|
||||
return {
|
||||
"default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT),
|
||||
"sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()},
|
||||
}
|
||||
|
||||
|
||||
def write_cross_layout(
|
||||
project_root: Path, longitudinal_path: Path, layout: dict[str, Any]
|
||||
) -> list[int | float]:
|
||||
"""배치를 쓰고 담긴 측점이 바뀐 확정 장을 푼다 — 풀린 장의 옛 측점들(설계 상태를 되돌릴 몫)."""
|
||||
checked = checked_cross_layout(layout)
|
||||
before = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
atomic_write_json(_design_root(project_root) / CROSS_LAYOUT_FILE, checked)
|
||||
after = {
|
||||
sheet["id"]: sheet["chainages"]
|
||||
for sheet in _cross_sheet_plan(project_root, longitudinal_path)
|
||||
}
|
||||
manifest = _read_manifest(project_root)
|
||||
stale: list[int | float] = []
|
||||
for sheet in before:
|
||||
entry = manifest["drawings"].get(sheet["id"])
|
||||
if entry and entry.get("confirmed") and after.get(sheet["id"]) != sheet["chainages"]:
|
||||
entry["confirmed"] = False
|
||||
stale.extend(sheet["chainages"])
|
||||
if stale:
|
||||
_write_manifest(project_root, manifest)
|
||||
return stale
|
||||
|
||||
|
||||
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
manifest_drawings = _read_manifest(project_root)["drawings"]
|
||||
@@ -123,7 +174,7 @@ def _drawing_list(
|
||||
# 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/`
|
||||
# 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적).
|
||||
try:
|
||||
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
|
||||
sheets = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
|
||||
sheets = []
|
||||
@@ -190,32 +241,21 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
|
||||
def _cross_sheet_plan(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int | float, dict[str, Any]] | None = None,
|
||||
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[str, Any]]:
|
||||
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
|
||||
|
||||
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
|
||||
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
|
||||
장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림).
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
blocks: list[tuple[int, float, float]] = []
|
||||
chainages: list[int | float] = []
|
||||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||||
match = _CROSS_ID.fullmatch(path.stem)
|
||||
if not match:
|
||||
continue
|
||||
source = _read_json(path)
|
||||
# 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
|
||||
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
|
||||
design = (designs or {}).get(chainage)
|
||||
design_line = _cross_design_line(longitudinal_path, source, design)
|
||||
overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3)
|
||||
width, height = section_block_size(source, design_line, design=design, overlay=overlay)
|
||||
blocks.append((chainage, width, height))
|
||||
return plan_cross_sheets(blocks)
|
||||
chainages.append(cross_station(source.get("chainage_m") or parse_cross_key(match.group(1))))
|
||||
return plan_cross_sheets(chainages, read_cross_layout(project_root))
|
||||
|
||||
|
||||
def _quantity_table(
|
||||
@@ -353,7 +393,7 @@ def _read_cross_sheet(
|
||||
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}."""
|
||||
designs = stored_designs if isinstance(stored_designs, dict) else {}
|
||||
overlays = overlays or {}
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
sheet = next((item for item in plan if item["id"] == drawing_id), None)
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
|
||||
@@ -13,6 +13,8 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import box as shapely_box
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
||||
@@ -499,6 +501,30 @@ def watershed_source(context: Any) -> dict[str, Any]:
|
||||
좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래
|
||||
미터 좌표가 나온다(2026-09-01).
|
||||
"""
|
||||
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
|
||||
basins = metric_basins(context)
|
||||
|
||||
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
|
||||
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
|
||||
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
|
||||
background = map_background(
|
||||
Path(context.project_root),
|
||||
context.crs,
|
||||
DRAWING_SCALE_BASIN,
|
||||
map_area_mm(),
|
||||
[*route_xy, *(point for basin in basins for point in basin["ring"])],
|
||||
)
|
||||
|
||||
return {
|
||||
"route_xy": route_xy,
|
||||
"basins": basins,
|
||||
"contours": background["contours"],
|
||||
"streams": background["streams"],
|
||||
}
|
||||
|
||||
|
||||
def metric_basins(context: Any) -> list[dict[str, Any]]:
|
||||
"""세부유역 [{ring, props}] — 사업지 m(유역도 · 평면도 유역 조각 141-3 공용)."""
|
||||
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
|
||||
to_basin_metric = Transformer.from_crs(
|
||||
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
|
||||
@@ -533,21 +559,36 @@ def watershed_source(context: Any) -> dict[str, Any]:
|
||||
_BASIN_MAX_DISTANCE_M / 1000.0,
|
||||
dropped,
|
||||
)
|
||||
return basins
|
||||
|
||||
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
|
||||
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
|
||||
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
|
||||
background = map_background(
|
||||
Path(context.project_root),
|
||||
context.crs,
|
||||
DRAWING_SCALE_BASIN,
|
||||
map_area_mm(),
|
||||
[*route_xy, *(point for basin in basins for point in basin["ring"])],
|
||||
)
|
||||
|
||||
return {
|
||||
"route_xy": route_xy,
|
||||
"basins": basins,
|
||||
"contours": background["contours"],
|
||||
"streams": background["streams"],
|
||||
}
|
||||
def plan_basin_pieces(
|
||||
context: Any, box: tuple[float, float, float, float]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 자름(141-3).
|
||||
|
||||
번호 = 유역도와 같은 번호(`index` · 없으면 차례) · 자리 = 잘린 조각 안 대표점.
|
||||
유역 저장본이 없으면 빈 조각(배수 분석 전).
|
||||
"""
|
||||
try:
|
||||
basins = metric_basins(context)
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
basins = []
|
||||
frame = shapely_box(*box)
|
||||
rings: list[dict[str, Any]] = []
|
||||
numbers: list[dict[str, Any]] = []
|
||||
for order, basin in enumerate(basins):
|
||||
number = int((basin.get("props") or {}).get("index") or order + 1)
|
||||
try:
|
||||
part = Polygon(basin["ring"]).buffer(0).intersection(frame)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if part.is_empty or part.area <= 0:
|
||||
continue
|
||||
for polygon in getattr(part, "geoms", [part]):
|
||||
if isinstance(polygon, Polygon) and polygon.area > 0:
|
||||
ring = [[round(x, 4), round(y, 4)] for x, y in polygon.exterior.coords]
|
||||
rings.append({"number": number, "ring": ring})
|
||||
point = part.representative_point()
|
||||
numbers.append({"number": number, "center": [round(point.x, 4), round(point.y, 4)]})
|
||||
return {"basin": {"items": rings}, "basin_no": {"items": numbers}}
|
||||
|
||||
@@ -31,6 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_structure_label,
|
||||
plan_area_mm,
|
||||
plan_chunks,
|
||||
sheet_links,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
DEFAULT_KINDS,
|
||||
@@ -49,6 +50,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
_plan_structures,
|
||||
_sheet_box,
|
||||
map_background,
|
||||
plan_basin_pieces,
|
||||
plan_stations,
|
||||
watershed_source,
|
||||
)
|
||||
@@ -262,16 +264,39 @@ def update_kind(
|
||||
"scale": chosen_scale,
|
||||
}
|
||||
atomic_write_json(path, _document(kind, order))
|
||||
_release_kinds(project_root, {kind_id})
|
||||
return kind
|
||||
|
||||
|
||||
def _release_kinds(project_root: Path, kind_ids: set[str]) -> None:
|
||||
"""그 종류들의 확정 평면도 장을 푼다 — 다시 그려야 하는 장(조각 · 축척 · 번호가 바뀜)."""
|
||||
manifest = _read_manifest(project_root)
|
||||
released = False
|
||||
for drawing_id, entry in manifest["drawings"].items():
|
||||
match = PLAN_KIND_ID.fullmatch(drawing_id)
|
||||
if match and match.group(1) == kind_id and entry.get("confirmed"):
|
||||
if match and match.group(1) in kind_ids and entry.get("confirmed"):
|
||||
entry["confirmed"] = False
|
||||
released = True
|
||||
if released:
|
||||
_write_manifest(project_root, manifest)
|
||||
return kind
|
||||
|
||||
|
||||
def reorder_kinds(project_root: Path, ids: list[str]) -> None:
|
||||
"""종류 차례를 ids 대로 다시 쓴다(140-2). 앞선 종류 묶음이 바뀐 종류 = 장 번호가 바뀜 → 확정 풂."""
|
||||
before = [kind["id"] for kind in _materialize(project_root)]
|
||||
if sorted(ids) != sorted(before):
|
||||
raise ValueError("종류 목록이 바뀌었습니다. 모달을 다시 열어 주세요.")
|
||||
folder = _kinds_dir(project_root)
|
||||
for order, kind_id in enumerate(ids):
|
||||
path = folder / f"{kind_id}.json"
|
||||
atomic_write_json(path, {**_read_json(path), "order": order})
|
||||
moved = {
|
||||
kind_id
|
||||
for index, kind_id in enumerate(ids)
|
||||
if set(ids[:index]) != set(before[: before.index(kind_id)])
|
||||
}
|
||||
if moved:
|
||||
_release_kinds(project_root, moved)
|
||||
|
||||
|
||||
def delete_kind(project_root: Path, kind_id: str) -> None:
|
||||
@@ -523,6 +548,8 @@ def build_pieces(
|
||||
and min_y <= item["y"] <= max_y
|
||||
]
|
||||
}
|
||||
if {"basin", "basin_no"} & set(wanted):
|
||||
pieces.update(plan_basin_pieces(context, box)) # 141-3
|
||||
if "lidar" in wanted:
|
||||
try:
|
||||
image, image_box = _hillshade_for_box(context, box)
|
||||
@@ -541,10 +568,15 @@ def build_pieces(
|
||||
pieces, _load_design_curves(project_root), route, box, chunk, total, mm
|
||||
)
|
||||
|
||||
chosen = {piece: pieces[piece] for piece in wanted if piece in pieces}
|
||||
if total > 1: # 여러 장 이음 — 조각 고름과 무관하게 늘(이웃 노선은 중심선 고를 때만 · 141-4)
|
||||
chosen["sheet_links"] = pieces["sheet_links"] = sheet_links(
|
||||
route, chunk, total, box, interval, mm, "centerline" in wanted
|
||||
)
|
||||
folder = _design_root(project_root) / "plan_pieces" / str(number)
|
||||
for piece, data in pieces.items():
|
||||
atomic_write_json(folder / f"{piece}.json", data)
|
||||
return {piece: pieces[piece] for piece in wanted if piece in pieces}, box, chunk, total
|
||||
return chosen, box, chunk, total
|
||||
|
||||
|
||||
def plan_kind_source(
|
||||
@@ -573,7 +605,7 @@ def plan_kind_source(
|
||||
)
|
||||
title = plan_title(kind["name"])
|
||||
return {
|
||||
"label": title if total <= 1 else f"{title} {chunk['number']}장",
|
||||
"label": title if total <= 1 else f"{title} {chunk['number']}/{total}장",
|
||||
"box": box,
|
||||
"pieces": pieces,
|
||||
"scale": kind["scale"],
|
||||
@@ -607,7 +639,7 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di
|
||||
"id": f"plan_{kind['id']}"
|
||||
if single
|
||||
else f"plan_{kind['id']}_{chunk['number']}",
|
||||
"label": title if single else f"{title} {chunk['number']}장",
|
||||
"label": title if single else f"{title} {chunk['number']}/{len(sheets)}장",
|
||||
"plan_kind": kind["id"],
|
||||
"plan_kind_name": kind["name"],
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
import {
|
||||
buildCrossLayoutControls,
|
||||
buildDesignInfoPanel,
|
||||
buildDrawingSidePanel,
|
||||
isCrossSheet,
|
||||
@@ -36,14 +37,17 @@ import {
|
||||
import {
|
||||
confirmDesignDrawing,
|
||||
exportDrawing,
|
||||
fetchCrossLayout,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
invalidateDesignDrawing,
|
||||
resetFrameTemplate,
|
||||
saveCrossLayout,
|
||||
saveFrameTemplate,
|
||||
type CadDrawing,
|
||||
type CrossLayout,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
@@ -81,6 +85,8 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
|
||||
let reopenDrawingId: string | undefined;
|
||||
/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */
|
||||
let reopenCrossSheet: number | undefined;
|
||||
|
||||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
@@ -138,8 +144,42 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
|
||||
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
|
||||
const scope = isCrossSheet(drawing) ? "sheet" : "station";
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
|
||||
const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope);
|
||||
infoPanelHost.replaceChildren(panel);
|
||||
if (scope === "sheet") void appendCrossLayout(drawing, panel);
|
||||
};
|
||||
|
||||
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */
|
||||
const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => {
|
||||
if (!projectId) return;
|
||||
const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1;
|
||||
let layout: CrossLayout;
|
||||
try {
|
||||
layout = await fetchCrossLayout(projectId);
|
||||
} catch {
|
||||
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
|
||||
}
|
||||
if (currentDrawing?.id !== drawing.id || !panel.isConnected) return;
|
||||
const save = async (next: CrossLayout) => {
|
||||
if (!mayDiscardEdits()) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveCrossLayout(projectId, next);
|
||||
reopenCrossSheet = number;
|
||||
cad.destroy();
|
||||
void renderB07DesignDetail(root);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
panel.append(
|
||||
buildCrossLayoutControls(
|
||||
layout.sheets[String(number)] ?? layout.default,
|
||||
(grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }),
|
||||
(grid) => void save({ default: grid, sheets: {} }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -523,8 +563,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
if (drawings.length > 0) {
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenDrawingId);
|
||||
const crossSheets = drawings.filter(isCrossSheet);
|
||||
const reopenId = reopenCrossSheet
|
||||
? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id
|
||||
: reopenDrawingId;
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenId);
|
||||
reopenDrawingId = undefined;
|
||||
reopenCrossSheet = undefined;
|
||||
const first = Math.max(reopen, 0);
|
||||
void openTab(drawings[first]).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
@@ -171,6 +172,58 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
|
||||
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
|
||||
}
|
||||
|
||||
/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */
|
||||
export function buildCrossLayoutControls(
|
||||
grid: [number, number],
|
||||
onApply: (grid: [number, number]) => void,
|
||||
onAllDefault: (grid: [number, number]) => void,
|
||||
): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b07-info__layout";
|
||||
const number = (value: number, label: string): HTMLInputElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "1";
|
||||
input.max = "10";
|
||||
input.step = "1";
|
||||
input.value = String(value);
|
||||
input.ariaLabel = label;
|
||||
return input;
|
||||
};
|
||||
const cols = number(grid[0], "열");
|
||||
const rows = number(grid[1], "행");
|
||||
const read = (): [number, number] | null => {
|
||||
const value: [number, number] = [Number(cols.value), Number(rows.value)];
|
||||
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
|
||||
};
|
||||
const action = (text: string, title: string, run: (grid: [number, number]) => void) => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button";
|
||||
node.className = "b07-plan-tools__button";
|
||||
node.textContent = text;
|
||||
node.title = title;
|
||||
node.addEventListener("click", () => {
|
||||
const value = read();
|
||||
if (value) run(value);
|
||||
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
|
||||
});
|
||||
return node;
|
||||
};
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "배치 열";
|
||||
const times = document.createElement("span");
|
||||
times.textContent = "× 행";
|
||||
box.append(
|
||||
label,
|
||||
cols,
|
||||
times,
|
||||
rows,
|
||||
action("적용", "이 장만 이 배치로", onApply),
|
||||
action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createSelectField, showToast } from "@ui/ui_template_elements";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
addPlanKind,
|
||||
deletePlanKind,
|
||||
savePlanMarks,
|
||||
updatePlanKind,
|
||||
type DesignDrawingItem,
|
||||
@@ -60,8 +61,20 @@ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
|
||||
{ value: "watershed", text: "유역도" },
|
||||
];
|
||||
|
||||
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */
|
||||
export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
/** 종류 폼 — 밖(장 단추)에서 고른 종류로 폼을 맞추는 `pick` 과 함께. */
|
||||
export interface KindForm {
|
||||
root: HTMLElement;
|
||||
pick: (kindId: string) => void;
|
||||
}
|
||||
|
||||
/** 종류 — 맨 위 [종류 ▼] 아래 상세. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · [지우기] ·
|
||||
* 「새 종류」면 양식 고르고 [추가]. onPick = 드롭다운으로 고른 종류(미리보기 따라감 · 140-2). */
|
||||
export function kindForm(
|
||||
state: PlanKindsState,
|
||||
controls: PlanKindsControls,
|
||||
selected = "",
|
||||
onPick?: (kindId: string) => void,
|
||||
): KindForm {
|
||||
const form = el("div", "b07-plan-tools__block");
|
||||
form.append(el("strong", "", "종류"));
|
||||
const pick = createSelectField({
|
||||
@@ -71,7 +84,10 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT
|
||||
],
|
||||
compact: true,
|
||||
ariaLabel: "종류",
|
||||
onChange: () => show(),
|
||||
onChange: () => {
|
||||
show();
|
||||
if (pick.select.value) onPick?.(pick.select.value);
|
||||
},
|
||||
});
|
||||
const layout = createSelectField({
|
||||
options: LAYOUTS,
|
||||
@@ -91,6 +107,8 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT
|
||||
pieceList.append(...boxes.map((box) => box.root));
|
||||
const action = el("button", "b07-plan-tools__button");
|
||||
action.type = "button";
|
||||
const remove = el("button", "b07-plan-tools__button", "지우기");
|
||||
remove.type = "button";
|
||||
// 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다.
|
||||
const showPieces = (): void => {
|
||||
const watershed = layout.select.value === "watershed";
|
||||
@@ -118,9 +136,18 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT
|
||||
layout.select.value = kind?.layout ?? "plan";
|
||||
layout.select.disabled = Boolean(kind);
|
||||
action.textContent = kind ? "업데이트" : "추가";
|
||||
remove.hidden = !kind;
|
||||
showPieces();
|
||||
};
|
||||
if (state.kinds.some((kind) => kind.id === selected)) pick.select.value = selected;
|
||||
show();
|
||||
remove.addEventListener("click", () => {
|
||||
const kind = state.kinds.find((item) => item.id === pick.select.value);
|
||||
if (!kind) return;
|
||||
if (!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
|
||||
return;
|
||||
void run(() => deletePlanKind(controls.projectId, kind.id), controls);
|
||||
});
|
||||
action.addEventListener("click", () => {
|
||||
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
|
||||
const kindId = pick.select.value;
|
||||
@@ -135,8 +162,16 @@ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HT
|
||||
controls,
|
||||
);
|
||||
});
|
||||
form.append(pick.root, layout.root, scale.root, name, pieceList, action);
|
||||
return form;
|
||||
const actions = el("div", "b07-plan-tools__actions");
|
||||
actions.append(action, remove);
|
||||
form.append(pick.root, layout.root, scale.root, name, pieceList, actions);
|
||||
return {
|
||||
root: form,
|
||||
pick: (kindId) => {
|
||||
pick.select.value = kindId;
|
||||
show();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* B07_DesignDetail_UI_PlanManage.ts
|
||||
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음.
|
||||
*
|
||||
* 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록
|
||||
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기]
|
||||
* 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고,
|
||||
* 왼쪽 = [종류 ▼] 상세 폼(추가 · 업데이트 · 지우기) · 평면도 장(종류마다 한 줄 [≡] 이름 [1][2] ·
|
||||
* 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) · 대피소 · 사토장 자리
|
||||
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기]
|
||||
* 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고,
|
||||
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -12,11 +13,10 @@ import { showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createCadHost } from "@ui/cad_host/cad_host";
|
||||
import {
|
||||
deletePlanKind,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchPlanKinds,
|
||||
invalidateDesignDrawing,
|
||||
reorderPlanKinds,
|
||||
type CadDrawing,
|
||||
type DesignDrawingItem,
|
||||
type PlanKindsState,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
kindForm,
|
||||
marksForm,
|
||||
run,
|
||||
type KindForm,
|
||||
type PlanKindsControls,
|
||||
} from "./B07_DesignDetail_UI_PlanKinds";
|
||||
|
||||
@@ -59,6 +60,10 @@ export function openPlanManager(controls: PlanManageControls): void {
|
||||
let closed = false;
|
||||
/** 미리보기 중인 장 — 늦게 온 응답이 다른 장을 덮지 않게 이것과 맞춰 봄. */
|
||||
let picked: string | undefined;
|
||||
/** 드롭다운 · 줄 강조의 종류 — 장 단추를 누르면 그 장의 종류로 따라감. */
|
||||
let pickedKind = "";
|
||||
let form: KindForm | undefined;
|
||||
let sheets: DesignDrawingItem[] = [];
|
||||
const preview = createCadHost<CadDrawing>({ title: "평면도 미리보기" });
|
||||
const side = el("div", "b07-plan-manage__side");
|
||||
const head = el("div", "b07-plan-manage__head");
|
||||
@@ -74,48 +79,90 @@ export function openPlanManager(controls: PlanManageControls): void {
|
||||
},
|
||||
};
|
||||
|
||||
const kindList = (state: PlanKindsState, sheets: DesignDrawingItem[]): HTMLElement => {
|
||||
const list = el("div", "b07-plan-tools__block b07-plan-manage__kinds");
|
||||
list.append(el("strong", "", "종류 목록"));
|
||||
const kindSheets = (kindId: string) =>
|
||||
groupPlanSheets(sheets.filter((item) => item.plan_kind === kindId))[0]?.sheets ?? [];
|
||||
|
||||
/** 장 단추 → 미리보기 + 드롭다운이 그 종류로. */
|
||||
const pickSheet = (drawing: DesignDrawingItem, label: string): void => {
|
||||
pickedKind = drawing.plan_kind ?? "";
|
||||
form?.pick(pickedKind);
|
||||
void show(drawing, label);
|
||||
};
|
||||
|
||||
/** 「평면도 장」 한 블록 — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서(놓는 즉시). */
|
||||
const sheetBlock = (state: PlanKindsState): HTMLElement => {
|
||||
const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets");
|
||||
list.append(el("strong", "", "평면도 장"));
|
||||
const ids = state.kinds.map((kind) => kind.id);
|
||||
let dragId: string | null = null;
|
||||
for (const kind of state.kinds) {
|
||||
const row = el("div", "b07-plan-kind__head");
|
||||
const row = el("div", "b07-plan-kind__head b07-plan-manage__row");
|
||||
row.dataset.planKind = kind.id;
|
||||
const count = sheets.filter((item) => item.plan_kind === kind.id).length;
|
||||
row.append(el("span", "b07-plan-kind__name", `${kind.name} · ${count}장`));
|
||||
const remove = button("b07-plan-tools__remove", "×", () => {
|
||||
if (
|
||||
!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)
|
||||
)
|
||||
return;
|
||||
void run(() => deletePlanKind(projectId, kind.id), edits);
|
||||
row.draggable = true;
|
||||
const grip = el("span", "b07-plan-manage__grip", "≡");
|
||||
grip.title = "끌어 놓아 종류 순서 바꾸기";
|
||||
const group = kindSheets(kind.id);
|
||||
// 한 장이면 이름이 곧 장 단추 · 여러 장이면 이름 + 작은 장 단추
|
||||
const name =
|
||||
group.length === 1
|
||||
? button("b07-plan-manage__sheet b07-plan-kind__name", kind.name, () =>
|
||||
pickSheet(group[0].drawing, group[0].label),
|
||||
)
|
||||
: el("span", "b07-plan-kind__name", kind.name);
|
||||
if (group.length === 1) {
|
||||
name.dataset.drawingId = group[0].drawing.id;
|
||||
name.dataset.confirmed = String(group[0].drawing.confirmed);
|
||||
}
|
||||
row.append(grip, name);
|
||||
if (group.length > 1)
|
||||
group.forEach(({ drawing, label }, index) => {
|
||||
const item = button("b07-plan-manage__sheet", String(index + 1), () =>
|
||||
pickSheet(drawing, label),
|
||||
);
|
||||
item.title = label;
|
||||
item.dataset.drawingId = drawing.id;
|
||||
item.dataset.confirmed = String(drawing.confirmed);
|
||||
row.append(item);
|
||||
});
|
||||
// 끌기 꼴 = 공용 탭 줄(`cad_host_tabs.ts`) — 세로라 위 · 아래 반으로 앞 · 뒤
|
||||
row.addEventListener("dragstart", (event) => {
|
||||
dragId = kind.id;
|
||||
event.dataTransfer?.setData("text/plain", kind.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
row.addEventListener("dragend", () => (dragId = null));
|
||||
row.addEventListener("dragover", (event) => {
|
||||
if (!dragId) return;
|
||||
event.preventDefault();
|
||||
const box = row.getBoundingClientRect();
|
||||
row.dataset.drop = event.clientY > box.top + box.height / 2 ? "after" : "before";
|
||||
});
|
||||
row.addEventListener("dragleave", () => delete row.dataset.drop);
|
||||
row.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
const after = row.dataset.drop === "after";
|
||||
delete row.dataset.drop;
|
||||
if (!dragId || dragId === kind.id) return;
|
||||
const order = ids.filter((id) => id !== dragId);
|
||||
order.splice(order.indexOf(kind.id) + (after ? 1 : 0), 0, dragId);
|
||||
dragId = null;
|
||||
if (order.join() !== ids.join()) void run(() => reorderPlanKinds(projectId, order), edits);
|
||||
});
|
||||
remove.title = `「${kind.name}」 종류 지우기`;
|
||||
row.append(remove);
|
||||
list.append(row);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const sheetList = (sheets: DesignDrawingItem[]): HTMLElement => {
|
||||
const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets");
|
||||
list.append(el("strong", "", "평면도 장"));
|
||||
for (const group of groupPlanSheets(sheets)) {
|
||||
for (const { drawing, label } of group.sheets) {
|
||||
const item = button("b07-drawing-button", label, () => void show(drawing, label));
|
||||
item.dataset.drawingId = drawing.id;
|
||||
item.dataset.confirmed = String(drawing.confirmed);
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
const markActive = (): void => {
|
||||
side
|
||||
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheet")
|
||||
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
|
||||
side
|
||||
.querySelectorAll<HTMLElement>(".b07-plan-manage__row")
|
||||
.forEach((row) => (row.dataset.active = String(row.dataset.planKind === pickedKind)));
|
||||
};
|
||||
|
||||
const markActive = (): void =>
|
||||
side
|
||||
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheets .b07-drawing-button")
|
||||
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
|
||||
|
||||
/** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */
|
||||
/** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */
|
||||
const show = async (drawing: DesignDrawingItem, label: string): Promise<void> => {
|
||||
picked = drawing.id;
|
||||
markActive();
|
||||
@@ -127,12 +174,6 @@ export function openPlanManager(controls: PlanManageControls): void {
|
||||
const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전");
|
||||
badge.dataset.confirmed = String(response.confirmed);
|
||||
head.append(badge);
|
||||
if (response.confirmed)
|
||||
head.append(
|
||||
button("b07-plan-tools__button", "확정 풀기", () => {
|
||||
void run(() => invalidateDesignDrawing(projectId, drawing.id), edits);
|
||||
}),
|
||||
);
|
||||
head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id)));
|
||||
preview.load(
|
||||
response.drawing,
|
||||
@@ -146,6 +187,7 @@ export function openPlanManager(controls: PlanManageControls): void {
|
||||
hostTitle: "평면도 미리보기",
|
||||
ribbonCollapsed: true,
|
||||
panelsCollapsed: true,
|
||||
bare: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -162,15 +204,18 @@ export function openPlanManager(controls: PlanManageControls): void {
|
||||
fetchDesignDrawingList(projectId),
|
||||
]);
|
||||
if (closed) return;
|
||||
const sheets = list.drawings.filter((item) => item.kind === "plan");
|
||||
side.replaceChildren(
|
||||
kindList(state, sheets),
|
||||
kindForm(state, edits),
|
||||
marksForm(state, edits),
|
||||
sheetList(sheets),
|
||||
);
|
||||
sheets = list.drawings.filter((item) => item.kind === "plan");
|
||||
const all = groupPlanSheets(sheets).flatMap((group) => group.sheets);
|
||||
const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0];
|
||||
if (keep) pickedKind = keep.drawing.plan_kind ?? "";
|
||||
// 드롭다운으로 고른 종류 → 그 종류 첫 장 미리보기 + 줄 강조
|
||||
form = kindForm(state, edits, pickedKind, (kindId) => {
|
||||
pickedKind = kindId;
|
||||
const first = kindSheets(kindId)[0];
|
||||
if (first) void show(first.drawing, first.label);
|
||||
else markActive();
|
||||
});
|
||||
side.replaceChildren(form.root, sheetBlock(state), marksForm(state, edits));
|
||||
if (keep) void show(keep.drawing, keep.label);
|
||||
else {
|
||||
picked = undefined;
|
||||
|
||||
@@ -225,6 +225,20 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */
|
||||
.b07-info__layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-info__layout input {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
@@ -310,6 +324,56 @@
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* 「평면도 장」 한 블록(140-2) — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서 */
|
||||
.b07-plan-manage__row {
|
||||
padding: 2px var(--spacing-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
}
|
||||
|
||||
.b07-plan-manage__row[data-active="true"] {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.b07-plan-manage__row[data-drop="before"] {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.b07-plan-manage__row[data-drop="after"] {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.b07-plan-manage__grip {
|
||||
color: var(--color-text-muted);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.b07-plan-manage__sheet {
|
||||
min-width: 26px;
|
||||
padding: 2px var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b07-plan-manage__sheet.b07-plan-kind__name {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.b07-plan-manage__sheet[data-confirmed="true"] {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.b07-plan-manage__sheet[data-active="true"] {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b07-plan-tools__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -58,6 +58,10 @@ button {
|
||||
.cad-host-save {
|
||||
--cad-title-height: 0px;
|
||||
}
|
||||
.cad-bare {
|
||||
--cad-title-height: 0px;
|
||||
--cad-status-height: 0px;
|
||||
}
|
||||
.controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Ribbon } from '../ribbon/Ribbon';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { useCadRefresh } from './use-cad-refresh';
|
||||
import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state';
|
||||
import { getActiveToolActor } from '../state';
|
||||
import { getActiveToolActor, isBare } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import { ViewControls } from './ViewControls';
|
||||
import { DropHint } from './DropHint';
|
||||
@@ -26,21 +26,27 @@ export const Toolbar: FC = () => {
|
||||
const ribbonCollapsed = isRibbonCollapsed();
|
||||
// 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다
|
||||
const panelCollapsed = isInspectorCollapsed();
|
||||
// 캔버스만(B07 평면도 관리 모달) — 겉칸을 안 그리고 높이 · 폭 변수를 0 으로
|
||||
const bare = isBare();
|
||||
|
||||
useEffect(() => {
|
||||
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
|
||||
// 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨).
|
||||
if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px');
|
||||
if (panelCollapsed || bare) document.documentElement.style.setProperty('--cad-panel-width', '0px');
|
||||
else document.documentElement.style.removeProperty('--cad-panel-width');
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-command-height',
|
||||
commandLineVisible ? '86px' : '0px'
|
||||
commandLineVisible && !bare ? '86px' : '0px'
|
||||
);
|
||||
// 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림).
|
||||
if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
|
||||
if (bare) document.documentElement.style.setProperty('--cad-ribbon-height', '0px');
|
||||
else if (ribbonCollapsed)
|
||||
document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
|
||||
else document.documentElement.style.removeProperty('--cad-ribbon-height');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed, commandLineVisible, ribbonCollapsed]);
|
||||
}, [panelCollapsed, commandLineVisible, ribbonCollapsed, bare]);
|
||||
|
||||
if (bare) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
setLayers,
|
||||
setOriginMarker,
|
||||
setViewOnly,
|
||||
setBare,
|
||||
} from '../state.ts';
|
||||
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -68,6 +69,8 @@ interface DrawingLoadMessage {
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
viewOnly?: boolean;
|
||||
/** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그림(B07 평면도 관리 모달). */
|
||||
bare?: boolean;
|
||||
originMarker?: boolean;
|
||||
}
|
||||
|
||||
@@ -227,6 +230,7 @@ export function registerAisloDrawingBridge() {
|
||||
setRibbonCollapsed(event.data.ribbonCollapsed === true);
|
||||
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
|
||||
setViewOnly(event.data.viewOnly === true);
|
||||
setBare(event.data.bare === true);
|
||||
setOriginMarker(event.data.originMarker === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
getEntities,
|
||||
getHighlightedEntityIds,
|
||||
isDrawingDirty,
|
||||
isBare,
|
||||
isDrawingReadOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setBare,
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setHighlightedEntityIds,
|
||||
@@ -120,3 +122,23 @@ describe('isDrawingReadOnly', () => {
|
||||
expect(isDrawingReadOnly()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBare', () => {
|
||||
it('캔버스만 — 켜면 뿌리에 cad-bare(제목줄 · 상태막대 0), 끄면 걷힌다', () => {
|
||||
stubWindow();
|
||||
const classes = new Set<string>();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
documentElement: {
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
},
|
||||
},
|
||||
};
|
||||
setBare(true);
|
||||
expect(isBare()).toBe(true);
|
||||
expect(classes.has('cad-bare')).toBe(true);
|
||||
setBare(false);
|
||||
expect(isBare()).toBe(false);
|
||||
expect(classes.has('cad-bare')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +197,8 @@ let viewOnly = false;
|
||||
let originMarker = false;
|
||||
let hostTitle = 'B07 상세 설계';
|
||||
let hostSave = false;
|
||||
/** 캔버스만(B07 평면도 관리 모달) — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다. */
|
||||
let bare = false;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -512,6 +514,13 @@ export const setHostSave = (enabled: boolean) => {
|
||||
// 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`).
|
||||
document.documentElement.classList.toggle('cad-host-save', enabled);
|
||||
};
|
||||
export const isBare = (): boolean => bare;
|
||||
export const setBare = (enabled: boolean) => {
|
||||
bare = enabled;
|
||||
// 제목 줄 · 상태막대 높이를 걷는다(App.css `.cad-bare`) — 리본 · 패널은 Toolbar 가 0 으로.
|
||||
document.documentElement.classList.toggle('cad-bare', enabled);
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const getHostTitle = (): string => hostTitle;
|
||||
export const setHostTitle = (title: string) => {
|
||||
hostTitle = title;
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
|
||||
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
|
||||
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
|
||||
열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}(145-3) — 주인 id = `linked_snapshots` 키
|
||||
(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 ·
|
||||
합 = 열 계.
|
||||
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
|
||||
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
|
||||
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
|
||||
@@ -111,6 +114,21 @@ def _combo(item: Any) -> str | None:
|
||||
return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}"
|
||||
|
||||
|
||||
class _Copy(str):
|
||||
"""잇기 조합 id + 그 사본 주인 id(`linked_snapshots` 키 · `{구조물 id}__{그룹}`) — 145-3."""
|
||||
|
||||
copy: str
|
||||
|
||||
|
||||
def _linked(combo: str | None, copy: str) -> str | None:
|
||||
"""잇기 조합에 사본 주인 id 를 붙임 — 본체 조합(그냥 글)은 시설 id 가 주인."""
|
||||
if combo is None:
|
||||
return None
|
||||
out = _Copy(combo)
|
||||
out.copy = copy
|
||||
return out
|
||||
|
||||
|
||||
def one_pipe_length(project_root: str | Path) -> float | None:
|
||||
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
|
||||
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
|
||||
@@ -193,14 +211,19 @@ class _Sheet:
|
||||
return
|
||||
cid = f"pipe:{key}"
|
||||
column = self.columns.setdefault(
|
||||
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
|
||||
cid,
|
||||
{"id": cid, "group": group, "label": label, "combos": [], "owners": {}, "_sort": sort},
|
||||
)
|
||||
for combo in combos:
|
||||
if combo and combo not in column["combos"]:
|
||||
column["combos"].append(combo)
|
||||
column["combos"].append(str(combo))
|
||||
row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}})
|
||||
if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함
|
||||
row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3)
|
||||
# 사본 주인별 값(145-3 · 설계내역서가 대가 호표에 이음) — 잇기 사본이 먼저 · 없으면 본체
|
||||
owner = next((c.copy for c in combos if isinstance(c, _Copy)), self.owner)
|
||||
owners = column["owners"]
|
||||
owners[owner] = round(owners.get(owner, 0.0) + value, 3)
|
||||
|
||||
def result(self) -> dict[str, Any]:
|
||||
columns = sorted(self.columns.values(), key=lambda column: column["_sort"])
|
||||
@@ -477,7 +500,11 @@ def _drain(
|
||||
return
|
||||
# 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서
|
||||
required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS)
|
||||
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
|
||||
combos = {
|
||||
part: links.get(link)
|
||||
or _linked(_combo(group.get(link)), f"{sheet.owner}{GROUP_JOIN}{link}")
|
||||
for part, link in required.items()
|
||||
}
|
||||
if type_id == "ford_pavement":
|
||||
_pavement(sheet, at, base, combos, names)
|
||||
_chutes(sheet, at, options, links, pipe_names)
|
||||
@@ -533,7 +560,7 @@ def pipe_summary(
|
||||
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
|
||||
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
|
||||
if link:
|
||||
entry["links"][link] = combo
|
||||
entry["links"][link] = _linked(combo, item["복사본"])
|
||||
else:
|
||||
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
|
||||
|
||||
@@ -558,7 +585,9 @@ def pipe_summary(
|
||||
}
|
||||
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
|
||||
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
|
||||
body = links.get(body_link) or _combo(group.get(body_link))
|
||||
body = links.get(body_link) or _linked(
|
||||
_combo(group.get(body_link)), f"{owner}{GROUP_JOIN}{body_link}"
|
||||
)
|
||||
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
|
||||
no_box += type_id == "box_culvert" and counted and not box_length
|
||||
if type_id == "ford_bridge":
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""B09 설계내역서 항목 짜기(PLAN 145-3) — 서버 단독 · B08 수량 → 내역 줄(수량 · 단가 자리).
|
||||
|
||||
공종 나무 = 엑셀 기번3 공통 꼴 고정(`B09_Estimation_Calc_Bill.SECTIONS`) · 공구 안 나눔.
|
||||
토공(1.1~1.6) = B08 토공집계표 줄 짝(`EARTH` — 엑셀 항목 이름 ↔ 토공집계표 구분 · 종류 · 규격) ·
|
||||
단가 = 조합 밖 토공 산근 · 준비공 대가 — 원천 없음 → 빈칸 「연결 없음」(지시)
|
||||
구조물(2.1~2.3) = B08 구조물 집계표 열(사본 있을 때만 · 143) ·
|
||||
구조물군 → 공종 짝(`STRUCTURE_SECTION`)
|
||||
· 커플링 열은 자재(관급자재대)라 뺌 · 단가 = 대가 호표(145-2 가 `hopyo` 로 줌 · 그전엔 빈칸)
|
||||
운반(3) = 엑셀 운반공 줄 · 수량 = B08 총괄자재 짝 · 거리 · 단가 = 운반 산근(원천 없음 → 빈칸)
|
||||
자재대 · 관급자재대 = 화면 짝이 대비표 「별도」 · 「관급」 표시로 짬(사용자 조작 즉시)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
|
||||
|
||||
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
|
||||
WHY_READY = "준비공 대가 — 연결 없음"
|
||||
WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)"
|
||||
WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)"
|
||||
|
||||
#: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None)
|
||||
EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
|
||||
("1.1", "규준틀설치 종단규준틀", "개소", None),
|
||||
("1.1", "규준틀설치 횡단규준틀", "개소", None),
|
||||
("1.1", "제근", "㎡", None),
|
||||
("1.2", "절토(토사)", "㎥", ("흙깍기", "토사", None)),
|
||||
("1.2", "절토(연암)", "㎥", ("흙깍기", "연암", None)),
|
||||
("1.2", "암발파", "㎥", ("흙깍기", "경암", None)),
|
||||
("1.3", "구조물터파기(토사)", "㎥", ("구조물터파기", "토사", None)),
|
||||
("1.3", "구조물터파기(연암)", "㎥", ("구조물터파기", "연암", None)),
|
||||
("1.3", "구조물 되메우기", "㎥", ("되메우기", "", None)),
|
||||
("1.4", "측구파기(토사)", "㎥", ("측구 터파기", "토사", None)),
|
||||
("1.4", "측구파기(연암)", "㎥", ("측구 터파기", "연암", None)),
|
||||
("1.5", "유용토운반", "㎥", ("유용토 소계", "", None)),
|
||||
("1.5", "도자운반", "㎥", ("토사운반", "", "19 TON B/D")),
|
||||
("1.5", "덤프운반", "㎥", ("토사운반", "", "BH+DT+BH")),
|
||||
("1.6", "사토처리", "㎥", ("잔토처리", "", None)),
|
||||
("1.6", "성토사면다짐", "㎡", ("성토면다짐", "", None)),
|
||||
("1.6", "노면정리", "㎡", None),
|
||||
]
|
||||
|
||||
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
|
||||
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"}
|
||||
SKIP_COLUMNS = ("pipe:cp|",)
|
||||
|
||||
#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None)
|
||||
HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [
|
||||
("중기운반", "대", None),
|
||||
("기계운반", "대", None),
|
||||
("모래운반", "㎥", ("sand",)),
|
||||
("자갈운반", "㎥", ("gravel",)),
|
||||
("시멘트운반", "대", ("cement",)),
|
||||
("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")),
|
||||
]
|
||||
|
||||
|
||||
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
|
||||
group, kind, spec = want
|
||||
same = row.get("group") == group and (row.get("kind") or "") == kind
|
||||
return same and (spec is None or row.get("spec") == spec)
|
||||
|
||||
|
||||
def earth_items(earthwork: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = (earthwork or {}).get("summary") or []
|
||||
out = []
|
||||
for n, (section, name, unit, want) in enumerate(EARTH, 1):
|
||||
source = next((r for r in rows if want and _same(r, want)), None)
|
||||
out.append(
|
||||
{
|
||||
"key": f"earth:{n}",
|
||||
"section": section,
|
||||
"name": name,
|
||||
"spec": (source or {}).get("spec") or "",
|
||||
"unit": unit,
|
||||
"qty": source.get("value") if source else None,
|
||||
"note": "B08 토공집계표" if source else "",
|
||||
"ref": None,
|
||||
"why": WHY_READY if section == "1.1" else WHY_EARTH,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
columns = (summary or {}).get("columns") or []
|
||||
total = (summary or {}).get("total") or {}
|
||||
out = []
|
||||
for column in columns:
|
||||
cid = str(column.get("id") or "")
|
||||
if cid.startswith(SKIP_COLUMNS):
|
||||
continue
|
||||
section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2")
|
||||
name, spec, unit = _structure_row(column)
|
||||
combos = [c for c in column.get("combos") or [] if c]
|
||||
out.append(
|
||||
{
|
||||
"key": f"st:{cid}",
|
||||
"section": section,
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"qty": total.get(cid),
|
||||
"note": " · ".join(combos),
|
||||
"ref": {"tab": "dg_list", "key": cid},
|
||||
# 사본 주인마다 값(B08 145-3 덧칸) — 대가 블록마다 줄을 나눌 때 씀
|
||||
"owners": dict(column.get("owners") or {}),
|
||||
"why": WHY_STRUCTURE,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = ((materials or {}).get("material") or {}).get("rows") or []
|
||||
amount = {row.get("key"): row.get("amount") for row in rows}
|
||||
out = []
|
||||
for n, (name, unit, keys) in enumerate(HAUL, 1):
|
||||
qty = None
|
||||
if keys:
|
||||
found = [amount[k] for k in keys if amount.get(k) is not None]
|
||||
qty = round(sum(found), 6) if found else None
|
||||
out.append(
|
||||
{
|
||||
"key": f"haul:{n}",
|
||||
"section": "3",
|
||||
"name": name,
|
||||
"spec": "",
|
||||
"unit": unit,
|
||||
"qty": qty,
|
||||
"note": "B08 총괄자재" if qty is not None else "",
|
||||
"ref": None,
|
||||
"why": WHY_HAUL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def bill_items(b08: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"items": [
|
||||
*earth_items(b08.get("earthwork")),
|
||||
*structure_items(b08.get("summary")),
|
||||
*haul_items(b08.get("materials")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def hopyo_prices(project_root: Any) -> dict[str, dict[str, float]]:
|
||||
"""호표 단가 `{호표 자리 키: {labor, material, expense}}` —
|
||||
145-1 · 2(산근 · 대가)가 들어오면 채움 · 그전엔 빈 묶음(구조물 단가 = 빈칸 「연결 없음」)."""
|
||||
return {}
|
||||
@@ -0,0 +1,246 @@
|
||||
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
|
||||
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
|
||||
단가 = 호표(대가 · 산근 — 145-1 · 2 가 `inputs.hopyo` 로 줌) · 자재 = 대비표 적용 단가
|
||||
금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 합계 = 세 비목 합 ·
|
||||
조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작)
|
||||
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
|
||||
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
|
||||
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
|
||||
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, round_half, rounddown
|
||||
|
||||
#: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔)
|
||||
SECTIONS: list[tuple[str, str, str | None]] = [
|
||||
("1", "토공사", None),
|
||||
("1.1", "준비공", "1"),
|
||||
("1.2", "절토", "1"),
|
||||
("1.3", "구조물터파기 및 되메우기", "1"),
|
||||
("1.4", "측구파기", "1"),
|
||||
("1.5", "유용성토", "1"),
|
||||
("1.6", "사토 및 다짐공", "1"),
|
||||
("2", "구조물공", None),
|
||||
("2.1", "돌기슭막이공", "2"),
|
||||
("2.2", "배수공", "2"),
|
||||
("2.3", "기타구조물", "2"),
|
||||
("3", "운반공", None),
|
||||
("4", "자재대", None),
|
||||
("G", "관급자재대", None),
|
||||
]
|
||||
#: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액)
|
||||
DIRECT = ("1", "2", "3", "4")
|
||||
PARTS = ("labor", "material", "expense")
|
||||
|
||||
|
||||
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
|
||||
if prices is None:
|
||||
unit = {p: None for p in PARTS}
|
||||
else:
|
||||
unit = {p: num(prices.get(p)) for p in PARTS}
|
||||
out: dict[str, Any] = {f"price_{p}": unit[p] for p in PARTS}
|
||||
known = all(unit[p] is not None for p in PARTS)
|
||||
out["price_sum"] = unit["labor"] + unit["material"] + unit["expense"] if known else None
|
||||
for p in PARTS:
|
||||
out[p] = rounddown(qty * unit[p], 0) if qty is not None and unit[p] is not None else None
|
||||
filled = all(out[p] is not None for p in PARTS)
|
||||
out["sum"] = out["labor"] + out["material"] + out["expense"] if filled else None
|
||||
return out
|
||||
|
||||
|
||||
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None, gaps: list) -> dict[str, Any]:
|
||||
qty = num(item.get("qty"))
|
||||
row = {
|
||||
"kind": "item",
|
||||
"key": item["key"],
|
||||
"section": item["section"],
|
||||
"name": item.get("name") or "",
|
||||
"spec": item.get("spec") or "",
|
||||
"unit": item.get("unit") or "",
|
||||
"qty": qty,
|
||||
"note": item.get("note") or "",
|
||||
"ref": item.get("ref"),
|
||||
**_amounts(qty, prices),
|
||||
}
|
||||
if qty is None:
|
||||
gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
|
||||
if prices is None:
|
||||
gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음"))
|
||||
return row
|
||||
|
||||
|
||||
def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]:
|
||||
"""대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목."""
|
||||
qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m}
|
||||
out = []
|
||||
for row in compare:
|
||||
if row["supply"] is None or row["key"] not in qty_of:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"key": f"mat:{row['key']}",
|
||||
"section": "G" if row["supply"] == "관급" else "4",
|
||||
"name": row["name"],
|
||||
"spec": row["spec"],
|
||||
"unit": row["unit"],
|
||||
"qty": qty_of[row["key"]],
|
||||
"note": f"자재 {row['no']}",
|
||||
"ref": {"tab": "material", "key": row["key"]},
|
||||
"_price": row["applied"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
PriceOf = Callable[[str], dict[str, Any] | None]
|
||||
|
||||
|
||||
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
|
||||
"""구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) ·
|
||||
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례."""
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for owner, value in (item.get("owners") or {}).items():
|
||||
price = price_of(owner)
|
||||
tag = str(price.get("no") or "") if price else ""
|
||||
group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []})
|
||||
group["qty"] += num(value) or 0
|
||||
group["owners"].append(owner)
|
||||
out = []
|
||||
for tag, group in groups.items():
|
||||
part = {
|
||||
**item,
|
||||
"key": f"{item['key']}#{tag}" if tag else item["key"],
|
||||
"qty": round_half(group["qty"], 3),
|
||||
"note": " · ".join(t for t in (tag, item.get("note") or "") if t),
|
||||
"owners": {o: item["owners"][o] for o in group["owners"]},
|
||||
}
|
||||
out.append((part, group["price"]))
|
||||
return out
|
||||
|
||||
|
||||
def compute_bill(
|
||||
inputs: dict[str, Any],
|
||||
user: dict[str, Any],
|
||||
base: dict[str, Any],
|
||||
price_of: PriceOf | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
|
||||
주면 구조물 열을 블록마다 나눔."""
|
||||
gaps: list[dict[str, str]] = []
|
||||
hopyo = inputs.get("hopyo") or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in (inputs.get("bill") or {}).get("items") or []:
|
||||
if price_of is not None and item.get("owners"):
|
||||
for part, prices in split_by_block(item, price_of):
|
||||
items.append(_item_row(part, prices, gaps))
|
||||
continue
|
||||
ref = item.get("ref") or {}
|
||||
prices = hopyo.get(ref.get("key")) if ref.get("key") else None
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
for item in _material_items(inputs, base["compare"]):
|
||||
price = item["_price"]
|
||||
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
owner = [row for row in items if row["section"] == "G"]
|
||||
net = 0.0
|
||||
for row in owner:
|
||||
net += row["material"] or 0
|
||||
pct = num(((user.get("estimate") or {}).get("procurement_fee_pct")))
|
||||
fee = rounddown(net * pct / 100, 0) if pct is not None else None
|
||||
fee_row = {
|
||||
"kind": "item",
|
||||
"key": "fee",
|
||||
"section": "G",
|
||||
"name": "조달수수료",
|
||||
"spec": "관급 재료비 × 율",
|
||||
"unit": "%",
|
||||
"qty": pct,
|
||||
"note": "관급 순자재대 밑수",
|
||||
"ref": None,
|
||||
"price_labor": 0,
|
||||
"price_material": net,
|
||||
"price_expense": 0,
|
||||
"price_sum": net,
|
||||
"labor": 0 if pct is not None else None,
|
||||
"material": fee,
|
||||
"expense": 0 if pct is not None else None,
|
||||
"sum": fee,
|
||||
}
|
||||
if owner:
|
||||
items.append(fee_row)
|
||||
if pct is None:
|
||||
gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"))
|
||||
rows: list[dict[str, Any]] = []
|
||||
sums: dict[str, dict[str, Any]] = {}
|
||||
for code, name, parent in SECTIONS:
|
||||
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
|
||||
for row in items:
|
||||
code = row["section"]
|
||||
chain = [code]
|
||||
parent = next(p for c, _n, p in SECTIONS if c == code)
|
||||
if parent:
|
||||
chain.append(parent)
|
||||
for c in chain:
|
||||
for p in ("sum", *PARTS):
|
||||
sums[c][p] += row[p] or 0
|
||||
if row["sum"] is None:
|
||||
sums[c]["blank"] += 1
|
||||
for code, name, parent in SECTIONS:
|
||||
s = sums[code]
|
||||
rows.append(
|
||||
{
|
||||
"kind": "title",
|
||||
"key": f"sec:{code}",
|
||||
"section": code,
|
||||
"name": f"{code} {name}" if code != "G" else name,
|
||||
"sum": s["sum"],
|
||||
"labor": s["labor"],
|
||||
"material": s["material"],
|
||||
"expense": s["expense"],
|
||||
"blank": s["blank"],
|
||||
"note": ("(제외금액)" if code == "G" else "")
|
||||
+ (f" 빈 줄 {s['blank']}" if s["blank"] else ""),
|
||||
}
|
||||
)
|
||||
rows.extend(row for row in items if row["section"] == code)
|
||||
direct = {p: 0.0 for p in ("sum", *PARTS)}
|
||||
blank = 0
|
||||
for code in DIRECT:
|
||||
for p in direct:
|
||||
direct[p] += sums[code][p]
|
||||
blank += sums[code]["blank"]
|
||||
totals = {
|
||||
"direct": {**direct, "blank": blank},
|
||||
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
||||
}
|
||||
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
|
||||
known = blank == 0
|
||||
owner_known = all(row["sum"] is not None for row in owner)
|
||||
sums_out = {
|
||||
"material": direct["material"] if known else None,
|
||||
"labor": direct["labor"] if known else None,
|
||||
"expense": direct["expense"] if known else None,
|
||||
"supply_net": net if owner_known else None,
|
||||
}
|
||||
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
|
||||
|
||||
|
||||
def compute_estimation(
|
||||
inputs: dict[str, Any], user: dict[str, Any], price_of: PriceOf | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
|
||||
base = compute_base(inputs, user)
|
||||
bill = compute_bill(inputs, user, base, price_of)
|
||||
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Calc_Bill.ts
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
||||
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
computeBase,
|
||||
gap,
|
||||
num,
|
||||
roundHalf,
|
||||
rounddown,
|
||||
type Gap,
|
||||
type Num,
|
||||
type Row,
|
||||
} from "./B09_Estimation_Calc_Base";
|
||||
|
||||
/** 공종 나무 — [번호, 이름, 부모] */
|
||||
export const SECTIONS: Array<[string, string, string | null]> = [
|
||||
["1", "토공사", null],
|
||||
["1.1", "준비공", "1"],
|
||||
["1.2", "절토", "1"],
|
||||
["1.3", "구조물터파기 및 되메우기", "1"],
|
||||
["1.4", "측구파기", "1"],
|
||||
["1.5", "유용성토", "1"],
|
||||
["1.6", "사토 및 다짐공", "1"],
|
||||
["2", "구조물공", null],
|
||||
["2.1", "돌기슭막이공", "2"],
|
||||
["2.2", "배수공", "2"],
|
||||
["2.3", "기타구조물", "2"],
|
||||
["3", "운반공", null],
|
||||
["4", "자재대", null],
|
||||
["G", "관급자재대", null],
|
||||
];
|
||||
const DIRECT = ["1", "2", "3", "4"];
|
||||
const PARTS = ["labor", "material", "expense"];
|
||||
|
||||
function amounts(qty: Num, prices: Row | null): Row {
|
||||
const unit: Record<string, Num> = {};
|
||||
for (const p of PARTS) unit[p] = prices === null ? null : num(prices[p]);
|
||||
const out: Row = {};
|
||||
for (const p of PARTS) out[`price_${p}`] = unit[p];
|
||||
const known = PARTS.every((p) => unit[p] !== null);
|
||||
out.price_sum = known ? unit.labor! + unit.material! + unit.expense! : null;
|
||||
for (const p of PARTS)
|
||||
out[p] = qty !== null && unit[p] !== null ? rounddown(qty * unit[p]!, 0) : null;
|
||||
const filled = PARTS.every((p) => out[p] !== null);
|
||||
out.sum = filled ? out.labor + out.material + out.expense : null;
|
||||
return out;
|
||||
}
|
||||
|
||||
function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row {
|
||||
const qty = num(item.qty);
|
||||
const row: Row = {
|
||||
kind: "item",
|
||||
key: item.key,
|
||||
section: item.section,
|
||||
name: item.name || "",
|
||||
spec: item.spec || "",
|
||||
unit: item.unit || "",
|
||||
qty,
|
||||
note: item.note || "",
|
||||
ref: item.ref ?? null,
|
||||
...amounts(qty, prices),
|
||||
};
|
||||
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
|
||||
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
|
||||
return row;
|
||||
}
|
||||
|
||||
function materialItems(inputs: Row, compare: Row[]): Row[] {
|
||||
const qtyOf: Row = {};
|
||||
for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty;
|
||||
const out: Row[] = [];
|
||||
for (const row of compare) {
|
||||
if (row.supply === null || !(row.key in qtyOf)) continue;
|
||||
out.push({
|
||||
key: `mat:${row.key}`,
|
||||
section: row.supply === "관급" ? "G" : "4",
|
||||
name: row.name,
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: qtyOf[row.key],
|
||||
note: `자재 ${row.no}`,
|
||||
ref: { tab: "material", key: row.key },
|
||||
_price: row.applied,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
|
||||
export type PriceOf = (owner: string) => Row | null;
|
||||
|
||||
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
|
||||
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
|
||||
const groups = new Map<string, Row>();
|
||||
for (const [owner, value] of Object.entries(item.owners || {})) {
|
||||
const price = priceOf(owner);
|
||||
const tag = price ? String(price.no || "") : "";
|
||||
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
|
||||
const group = groups.get(tag)!;
|
||||
group.qty += num(value) || 0;
|
||||
group.owners.push(owner);
|
||||
}
|
||||
const out: Array<[Row, Row | null]> = [];
|
||||
for (const [tag, group] of groups) {
|
||||
const part: Row = {
|
||||
...item,
|
||||
key: tag ? `${item.key}#${tag}` : item.key,
|
||||
qty: roundHalf(group.qty, 3),
|
||||
note: [tag, item.note || ""].filter((t) => t).join(" · "),
|
||||
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
|
||||
};
|
||||
out.push([part, group.price]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeBill(
|
||||
inputs: Row,
|
||||
user: Row,
|
||||
base: Row,
|
||||
priceOf: PriceOf | null = null,
|
||||
): Row {
|
||||
const gaps: Gap[] = [];
|
||||
const hopyo: Row = inputs.hopyo || {};
|
||||
const items: Row[] = [];
|
||||
for (const item of (inputs.bill || {}).items || []) {
|
||||
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
|
||||
for (const [part, prices] of splitByBlock(item, priceOf))
|
||||
items.push(itemRow(part, prices, gaps));
|
||||
continue;
|
||||
}
|
||||
const ref: Row = item.ref || {};
|
||||
const prices = ref.key ? (hopyo[ref.key] ?? null) : null;
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
}
|
||||
for (const item of materialItems(inputs, base.compare)) {
|
||||
const price: Num = item._price;
|
||||
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
}
|
||||
const owner = items.filter((row) => row.section === "G");
|
||||
let net = 0;
|
||||
for (const row of owner) net += row.material || 0;
|
||||
const pct = num((user.estimate || {}).procurement_fee_pct);
|
||||
const fee = pct !== null ? rounddown((net * pct) / 100, 0) : null;
|
||||
const feeRow: Row = {
|
||||
kind: "item",
|
||||
key: "fee",
|
||||
section: "G",
|
||||
name: "조달수수료",
|
||||
spec: "관급 재료비 × 율",
|
||||
unit: "%",
|
||||
qty: pct,
|
||||
note: "관급 순자재대 밑수",
|
||||
ref: null,
|
||||
price_labor: 0,
|
||||
price_material: net,
|
||||
price_expense: 0,
|
||||
price_sum: net,
|
||||
labor: pct !== null ? 0 : null,
|
||||
material: fee,
|
||||
expense: pct !== null ? 0 : null,
|
||||
sum: fee,
|
||||
};
|
||||
if (owner.length) {
|
||||
items.push(feeRow);
|
||||
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
|
||||
}
|
||||
const sums: Record<string, Row> = {};
|
||||
for (const [code] of SECTIONS)
|
||||
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
|
||||
for (const row of items) {
|
||||
const code = row.section;
|
||||
const chain = [code];
|
||||
const parent = SECTIONS.find(([c]) => c === code)![2];
|
||||
if (parent) chain.push(parent);
|
||||
for (const c of chain) {
|
||||
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
|
||||
if (row.sum === null) sums[c].blank += 1;
|
||||
}
|
||||
}
|
||||
const rows: Row[] = [];
|
||||
for (const [code, name] of SECTIONS) {
|
||||
const s = sums[code];
|
||||
rows.push({
|
||||
kind: "title",
|
||||
key: `sec:${code}`,
|
||||
section: code,
|
||||
name: code !== "G" ? `${code} ${name}` : name,
|
||||
sum: s.sum,
|
||||
labor: s.labor,
|
||||
material: s.material,
|
||||
expense: s.expense,
|
||||
blank: s.blank,
|
||||
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
|
||||
});
|
||||
rows.push(...items.filter((row) => row.section === code));
|
||||
}
|
||||
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
|
||||
let blank = 0;
|
||||
for (const code of DIRECT) {
|
||||
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
|
||||
blank += sums[code].blank;
|
||||
}
|
||||
const totals = {
|
||||
direct: { ...direct, blank },
|
||||
owner: { net_material: net, fee, total: net + (fee || 0) },
|
||||
};
|
||||
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
|
||||
const known = blank === 0;
|
||||
const ownerKnown = owner.every((row) => row.sum !== null);
|
||||
const sumsOut = {
|
||||
material: known ? direct.material : null,
|
||||
labor: known ? direct.labor : null,
|
||||
expense: known ? direct.expense : null,
|
||||
supply_net: ownerKnown ? net : null,
|
||||
};
|
||||
return { rows, totals, sums: sumsOut, gaps };
|
||||
}
|
||||
|
||||
/** 144 바탕 + 145-3 설계내역서 */
|
||||
export function computeEstimation(inputs: Row, user: Row, priceOf: PriceOf | null = null): Row {
|
||||
const base = computeBase(inputs, user);
|
||||
const bill = computeBill(inputs, user, base, priceOf);
|
||||
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Total.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_4_b09_total_calc.py`.
|
||||
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
|
||||
|
||||
입력
|
||||
sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`)
|
||||
material 직접재료비 · labor 직접노무비 · expense 산출경비(관급 제외 순공사비계) ·
|
||||
supply_net 관급 순자재대(조달수수료 전)
|
||||
estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 ·
|
||||
profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 ·
|
||||
scrap 작업설 · procurement_fee_pct 조달수수료율
|
||||
식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) ·
|
||||
산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 ·
|
||||
이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정.
|
||||
빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 ·
|
||||
손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total
|
||||
|
||||
#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦
|
||||
RATE_LINES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"),
|
||||
("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"),
|
||||
("employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"),
|
||||
("health", "건강보험료", "RT000003", "직접노무비 × 율"),
|
||||
("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"),
|
||||
("pension", "연금보험료", "RT000004", "직접노무비 × 율"),
|
||||
("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"),
|
||||
("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"),
|
||||
("environment", "환경보전비", "RT000010", "순공사비 × 율"),
|
||||
("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"),
|
||||
)
|
||||
#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦)
|
||||
CURRENT_ONLY: tuple[tuple[str, str, str], ...] = (
|
||||
("retirement", "퇴직공제부금비", "RT000011"),
|
||||
("wage_claim", "임금채권보장분담금", "RT000012"),
|
||||
("asbestos", "석면피해구제분담금", "RT000013"),
|
||||
("subcontract", "하도급대금지급보증수수료", "RT000014"),
|
||||
("performance", "공사이행보증수수료", "RT000016"),
|
||||
)
|
||||
#: 소계 아래 줄 (키, 이름, 후보 키, 산식 글)
|
||||
TAIL_LINES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("overhead", "일반관리비", "RT000017", "나.소계 × 율"),
|
||||
("profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"),
|
||||
("vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"),
|
||||
)
|
||||
_SAFETY_VAT_DIVISOR = 1.1 # 관급 순자재대가 부가세 포함 — 1.1 로 나눠 대상액에
|
||||
_SAFETY_B = 1.2 # B안 = (관급 빼고) × 1.2
|
||||
|
||||
|
||||
def _pct(base: float | None, rate: float | None) -> float | None:
|
||||
"""ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸."""
|
||||
return None if base is None or rate is None else rounddown(base * rate / 100)
|
||||
|
||||
|
||||
def procurement_fee(net: float | None, pct: float | None) -> float | None:
|
||||
"""조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(엑셀 설계내역서 253 줄)."""
|
||||
return _pct(net, pct)
|
||||
|
||||
|
||||
def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""총괄 제비율 줄 · 소계 · 관급 · 원가계산서 줄 · 구성비."""
|
||||
sums = sums if isinstance(sums, dict) else {}
|
||||
est = estimate if isinstance(estimate, dict) else {}
|
||||
rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {}
|
||||
rate = {key: num(rates_in.get(key)) for key, *_ in (*RATE_LINES, *TAIL_LINES)}
|
||||
|
||||
def hand(key: str) -> float:
|
||||
return num(est.get(key)) or 0.0
|
||||
|
||||
material, labor = num(sums.get("material")), num(sums.get("labor"))
|
||||
expense, supply_net = num(sums.get("expense")), num(sums.get("supply_net"))
|
||||
direct = total([material, labor, expense]) # 가. 순공사비계
|
||||
|
||||
line: dict[str, float | None] = {}
|
||||
line["indirect_labor"] = _pct(labor, rate["indirect_labor"])
|
||||
total_labor = total([labor, line["indirect_labor"]])
|
||||
line["accident"] = _pct(total_labor, rate["accident"])
|
||||
line["employment"] = _pct(total_labor, rate["employment"])
|
||||
line["health"] = _pct(labor, rate["health"])
|
||||
line["care"] = _pct(line["health"], rate["care"])
|
||||
line["pension"] = _pct(labor, rate["pension"])
|
||||
# 산업안전보건관리비 — A(관급 순자재대 ÷ 1.1 포함) · B(× 1.2) 중 작은 쪽(관급 없으면 A)
|
||||
safety_a = safety_b = None
|
||||
labor_material = total([labor, material])
|
||||
if labor_material is not None and rate["safety"] is not None:
|
||||
base = hand("safety_base")
|
||||
target = labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR
|
||||
safety_a = rounddown(target * rate["safety"] / 100 + base)
|
||||
safety_b = rounddown((labor_material * rate["safety"] / 100 + base) * _SAFETY_B)
|
||||
line["safety"] = None if safety_a is None else min(safety_a, safety_b)
|
||||
line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"])
|
||||
line["environment"] = _pct(direct, rate["environment"])
|
||||
line["machine"] = _pct(direct, rate["machine"])
|
||||
net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가
|
||||
line["overhead"] = _pct(net_cost, rate["overhead"])
|
||||
da = total([net_cost, line["overhead"]]) # 다
|
||||
profit_raw = _pct(None if da is None or material is None else da - material, rate["profit"])
|
||||
line["profit"] = None if profit_raw is None else profit_raw - hand("profit_adjust")
|
||||
supply_price = total([da, line["profit"]]) # 라. 공급가액
|
||||
vat = _pct(supply_price, rate["vat"])
|
||||
line["vat"] = None if vat is None else vat + hand("vat_adjust")
|
||||
contract = total([supply_price, line["vat"]]) # 마. 도급공사비
|
||||
# 관급자재대 = ROUNDUP(순자재대 + 조달수수료, −3) · 관급 0 이면 0
|
||||
fee = (
|
||||
0.0 if supply_net == 0 else procurement_fee(supply_net, num(est.get("procurement_fee_pct")))
|
||||
)
|
||||
supply_raw = total([supply_net, fee])
|
||||
line["supply"] = None if supply_raw is None else roundup(supply_raw, -3)
|
||||
grand = total([contract, line["supply"]]) # 바. 총공사비
|
||||
|
||||
indirect_material, scrap = hand("indirect_material"), hand("scrap")
|
||||
cost = {
|
||||
"direct_material": None if material is None else material - indirect_material + scrap,
|
||||
"indirect_material": indirect_material,
|
||||
"scrap": scrap,
|
||||
"material": material,
|
||||
"direct_labor": labor,
|
||||
"indirect_labor": line["indirect_labor"],
|
||||
"labor": total_labor,
|
||||
"expense": expense,
|
||||
**{key: line[key] for key in ("accident", "employment", "health", "care", "pension")},
|
||||
**{key: line[key] for key in ("safety", "other", "environment", "machine")},
|
||||
"expense_sum": total(
|
||||
[expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")]
|
||||
),
|
||||
"overhead": line["overhead"],
|
||||
"profit": line["profit"],
|
||||
"supply_price": supply_price,
|
||||
"vat": line["vat"],
|
||||
"contract": contract,
|
||||
"supply": line["supply"],
|
||||
"grand": grand,
|
||||
}
|
||||
share = {
|
||||
key: (None if value is None or not grand else value / grand * 100)
|
||||
for key, value in cost.items()
|
||||
}
|
||||
return {
|
||||
"sums": {"material": material, "labor": labor, "expense": expense, "direct": direct},
|
||||
"lines": line,
|
||||
"subtotals": {
|
||||
"net_cost": net_cost,
|
||||
"da": da,
|
||||
"supply_price": supply_price,
|
||||
"contract": contract,
|
||||
"grand": grand,
|
||||
},
|
||||
"safety": {"a": safety_a, "b": safety_b},
|
||||
"profit_raw": profit_raw,
|
||||
"supply": {"net": supply_net, "fee": fee, "raw": supply_raw},
|
||||
"cost": cost,
|
||||
"share": share,
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Calc_Total.ts
|
||||
* B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 짝.
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Total.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`.
|
||||
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
|
||||
*
|
||||
* 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`)
|
||||
* 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base";
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */
|
||||
export const RATE_LINES: Array<[string, string, string, string]> = [
|
||||
["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"],
|
||||
["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"],
|
||||
["employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"],
|
||||
["health", "건강보험료", "RT000003", "직접노무비 × 율"],
|
||||
["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"],
|
||||
["pension", "연금보험료", "RT000004", "직접노무비 × 율"],
|
||||
["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"],
|
||||
["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"],
|
||||
["environment", "환경보전비", "RT000010", "순공사비 × 율"],
|
||||
["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"],
|
||||
];
|
||||
/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */
|
||||
export const CURRENT_ONLY: Array<[string, string, string]> = [
|
||||
["retirement", "퇴직공제부금비", "RT000011"],
|
||||
["wage_claim", "임금채권보장분담금", "RT000012"],
|
||||
["asbestos", "석면피해구제분담금", "RT000013"],
|
||||
["subcontract", "하도급대금지급보증수수료", "RT000014"],
|
||||
["performance", "공사이행보증수수료", "RT000016"],
|
||||
];
|
||||
/** 소계 아래 줄 */
|
||||
export const TAIL_LINES: Array<[string, string, string, string]> = [
|
||||
["overhead", "일반관리비", "RT000017", "나.소계 × 율"],
|
||||
["profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"],
|
||||
["vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"],
|
||||
];
|
||||
const SAFETY_VAT_DIVISOR = 1.1;
|
||||
const SAFETY_B = 1.2;
|
||||
|
||||
/** ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸 */
|
||||
const pct = (base: Num, rate: Num): Num =>
|
||||
base === null || rate === null ? null : rounddown((base * rate) / 100);
|
||||
|
||||
/** 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0) */
|
||||
export const procurementFee = (net: Num, rate: Num): Num => pct(net, rate);
|
||||
|
||||
export function computeTotal(sums: Row | null | undefined, estimate: Row | null | undefined): Row {
|
||||
const s: Row = sums && typeof sums === "object" ? sums : {};
|
||||
const est: Row = estimate && typeof estimate === "object" ? estimate : {};
|
||||
const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {};
|
||||
const rate: Record<string, Num> = {};
|
||||
for (const [key] of [...RATE_LINES, ...TAIL_LINES]) rate[key] = num(ratesIn[key]);
|
||||
const hand = (key: string): number => num(est[key]) || 0;
|
||||
|
||||
const material = num(s.material);
|
||||
const labor = num(s.labor);
|
||||
const expense = num(s.expense);
|
||||
const supplyNet = num(s.supply_net);
|
||||
const direct = total([material, labor, expense]);
|
||||
|
||||
const line: Record<string, Num> = {};
|
||||
line.indirect_labor = pct(labor, rate.indirect_labor);
|
||||
const totalLabor = total([labor, line.indirect_labor]);
|
||||
line.accident = pct(totalLabor, rate.accident);
|
||||
line.employment = pct(totalLabor, rate.employment);
|
||||
line.health = pct(labor, rate.health);
|
||||
line.care = pct(line.health, rate.care);
|
||||
line.pension = pct(labor, rate.pension);
|
||||
let safetyA: Num = null;
|
||||
let safetyB: Num = null;
|
||||
const laborMaterial = total([labor, material]);
|
||||
if (laborMaterial !== null && rate.safety !== null) {
|
||||
const base = hand("safety_base");
|
||||
const target = laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR;
|
||||
safetyA = rounddown((target * rate.safety) / 100 + base);
|
||||
safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B);
|
||||
}
|
||||
line.safety = safetyA === null || safetyB === null ? null : Math.min(safetyA, safetyB);
|
||||
line.other = pct(total([labor, line.indirect_labor, material]), rate.other);
|
||||
line.environment = pct(direct, rate.environment);
|
||||
line.machine = pct(direct, rate.machine);
|
||||
const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]);
|
||||
line.overhead = pct(netCost, rate.overhead);
|
||||
const da = total([netCost, line.overhead]);
|
||||
const profitRaw = pct(da === null || material === null ? null : da - material, rate.profit);
|
||||
line.profit = profitRaw === null ? null : profitRaw - hand("profit_adjust");
|
||||
const supplyPrice = total([da, line.profit]);
|
||||
const vat = pct(supplyPrice, rate.vat);
|
||||
line.vat = vat === null ? null : vat + hand("vat_adjust");
|
||||
const contract = total([supplyPrice, line.vat]);
|
||||
const fee = supplyNet === 0 ? 0 : procurementFee(supplyNet, num(est.procurement_fee_pct));
|
||||
const supplyRaw = total([supplyNet, fee]);
|
||||
line.supply = supplyRaw === null ? null : roundup(supplyRaw, -3);
|
||||
const grand = total([contract, line.supply]);
|
||||
|
||||
const indirectMaterial = hand("indirect_material");
|
||||
const scrap = hand("scrap");
|
||||
const cost: Record<string, Num> = {
|
||||
direct_material: material === null ? null : material - indirectMaterial + scrap,
|
||||
indirect_material: indirectMaterial,
|
||||
scrap,
|
||||
material,
|
||||
direct_labor: labor,
|
||||
indirect_labor: line.indirect_labor,
|
||||
labor: totalLabor,
|
||||
expense,
|
||||
accident: line.accident,
|
||||
employment: line.employment,
|
||||
health: line.health,
|
||||
care: line.care,
|
||||
pension: line.pension,
|
||||
safety: line.safety,
|
||||
other: line.other,
|
||||
environment: line.environment,
|
||||
machine: line.machine,
|
||||
expense_sum: total([
|
||||
expense,
|
||||
...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]),
|
||||
]),
|
||||
overhead: line.overhead,
|
||||
profit: line.profit,
|
||||
supply_price: supplyPrice,
|
||||
vat: line.vat,
|
||||
contract,
|
||||
supply: line.supply,
|
||||
grand,
|
||||
};
|
||||
const share: Record<string, Num> = {};
|
||||
for (const [key, value] of Object.entries(cost))
|
||||
share[key] = value === null || !grand ? null : (value / grand) * 100;
|
||||
return {
|
||||
sums: { material, labor, expense, direct },
|
||||
lines: line,
|
||||
subtotals: { net_cost: netCost, da, supply_price: supplyPrice, contract, grand },
|
||||
safety: { a: safetyA, b: safetyB },
|
||||
profit_raw: profitRaw,
|
||||
supply: { net: supplyNet, fee, raw: supplyRaw },
|
||||
cost,
|
||||
share,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄.
|
||||
|
||||
후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음.
|
||||
요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FILE = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "요율_조달청제비율.json"
|
||||
|
||||
|
||||
def _percents(value: Any) -> list[float]:
|
||||
"""값 안 모든 rate_percent — 구간 · 연도 · 공종 갈래 통째로."""
|
||||
if isinstance(value, dict):
|
||||
own = value.get("rate_percent")
|
||||
found = [float(own)] if isinstance(own, (int, float)) else []
|
||||
return found + [
|
||||
p for key, v in value.items() if key != "rate_percent" for p in _percents(v)
|
||||
]
|
||||
if isinstance(value, list):
|
||||
return [p for item in value for p in _percents(item)]
|
||||
return []
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def rate_candidates() -> dict[str, str]:
|
||||
"""{RT 키: 「M01 RT… 3.56%」 · 「… 1.01~1.57%(구간 · 고르는 값 사용자)」} — 파일 없으면 {}."""
|
||||
try:
|
||||
rows = json.loads(FILE.read_text(encoding="utf-8")).get("줄") or []
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for row in rows:
|
||||
percents = sorted(set(_percents(row.get("값"))))
|
||||
if not percents:
|
||||
text = "구간 식(고르는 값 사용자)"
|
||||
elif len(percents) == 1:
|
||||
text = f"{percents[0]:g}%"
|
||||
else:
|
||||
text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)"
|
||||
out[str(row.get("키"))] = f"M01 {row.get('키')} {text}"
|
||||
return out
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식).
|
||||
수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식) ·
|
||||
산근 = `B09_Estimation_Sangeun`(서버 단독 · 145-1 · 화면은 받은 그대로).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,14 +22,21 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -36,14 +44,37 @@ router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
|
||||
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
out["materials"] = await material_sheets(project_id)
|
||||
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
|
||||
notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}")
|
||||
out["materials"] = None
|
||||
for key, name, read in (
|
||||
("materials", "자재 집계표", material_sheets),
|
||||
("earthwork", "토공집계표", earthwork_table),
|
||||
):
|
||||
try:
|
||||
out[key] = await read(project_id)
|
||||
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
|
||||
notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}")
|
||||
out[key] = None
|
||||
# 구조물 집계표 = 자재 집계표가 이미 부른 값(열 · 계 · 조합) — 두 번 안 부름
|
||||
out["summary"] = (out["materials"] or {}).get("source")
|
||||
return out
|
||||
|
||||
|
||||
def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""짝 셈 한 벌 — 바탕(144) → 산근(145-1 · 서버 단독) → 대가(145-2) → 내역(145-3) → 총괄(145-4).
|
||||
|
||||
화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로)."""
|
||||
base = compute_base(inputs, user)
|
||||
sg = sangeun.build(root, base) # 145-1 — 굳힌 단가로 풀이
|
||||
by_key = {row["key"]: row for row in sg["sg_list"]}
|
||||
labor = compute_labor(inputs.get("daega"), {**base, "gaps": []}, by_key)
|
||||
sheets = compute_estimation(inputs, user, lambda owner: daega_price(labor, owner))
|
||||
sheets.update(sg)
|
||||
sheets.update({k: v for k, v in labor.items() if k != "gaps"})
|
||||
sheets["gaps"] = [*sheets["gaps"], *sg["sg_gaps"], *labor["gaps"]]
|
||||
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
|
||||
return sheets
|
||||
|
||||
|
||||
async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
notes: list[str] = []
|
||||
b08 = await _b08(project_id, notes)
|
||||
@@ -54,15 +85,20 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
inputs["bill"] = bill_items(b08)
|
||||
inputs["hopyo"] = hopyo_prices(root)
|
||||
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
base = compute_base(inputs, user)
|
||||
sheets = await asyncio.to_thread(compute_sheets, root, inputs, user)
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
"book": {"made": book.get("made")} if book else None,
|
||||
"sheets": {**base, **compute_labor(inputs["daega"], base)},
|
||||
"sheets": sheets,
|
||||
"rate_candidates": rate_candidates(),
|
||||
"notes": notes,
|
||||
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
|
||||
"summary": compute_summary(sheets, hopyo_lines(root)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함).
|
||||
|
||||
산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중
|
||||
`승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) +
|
||||
옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`)
|
||||
같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) ·
|
||||
번호 = 첫 등장 차례 ·
|
||||
로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근).
|
||||
풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고
|
||||
(M01 엔진 식 그대로) ·
|
||||
단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 ·
|
||||
중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 ·
|
||||
% 줄(덧줄) = 엔진 식에 B09 비목 합.
|
||||
끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) ·
|
||||
작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 ·
|
||||
줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합.
|
||||
빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음)
|
||||
+ 「연결 없음」.
|
||||
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
|
||||
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
linked_snapshots,
|
||||
structures_dir,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
mf = item_prices.mf
|
||||
PARTS = ("labor", "material", "expense")
|
||||
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||||
DEPTH = 4 # 하위 산근 깊이 상한
|
||||
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
|
||||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
|
||||
EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음"
|
||||
|
||||
|
||||
# ── 산근 정체 ─────────────────────────────────────────────────────────
|
||||
def _clean_given(given: Any) -> dict[str, Any]:
|
||||
return {
|
||||
str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄"
|
||||
}
|
||||
|
||||
|
||||
def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str:
|
||||
"""산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글.
|
||||
145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음."""
|
||||
body = {
|
||||
"입력값": _clean_given(given),
|
||||
"율": None if rate in (None, 100) else float(rate),
|
||||
"고름": {str(k): v for k, v in sorted((picks or {}).items())},
|
||||
}
|
||||
text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}"
|
||||
|
||||
|
||||
def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
"""산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None."""
|
||||
for row in sheets.get("sg_list") or []:
|
||||
if row["key"] == key:
|
||||
return {k: row[k] for k in ("no", *PARTS, "sum")}
|
||||
return None
|
||||
|
||||
|
||||
def _values(values: dict[str, Any], code: str) -> dict[str, Any]:
|
||||
return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()}
|
||||
|
||||
|
||||
def rows_of(folder: Path) -> list[dict[str, Any]]:
|
||||
"""사본 하나의 산근 원천 줄 — 승격 로직 줄 · 옛 SP 참조 줄(차례대로)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
if not isinstance(labor, dict):
|
||||
return []
|
||||
values = read_json(folder / VALUES_FILE) or {}
|
||||
picks = read_json(folder / PICKS_FILE) or {}
|
||||
copies = labor.get("참조사본") or {}
|
||||
out = []
|
||||
for one in (labor.get("줄") or {}).get("담은로직") or []:
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
ref = one.get("참조")
|
||||
if one.get("승격") is True and one.get("로직"):
|
||||
code = str(one["로직"])
|
||||
got = {
|
||||
"code": code,
|
||||
"given": _values(values, code),
|
||||
"rate": one.get("켜쌓기율"),
|
||||
"picks": picks.get(code) or one.get("고름"),
|
||||
}
|
||||
elif isinstance(ref, dict) and refs.kind_of(ref.get("키")) == "promote":
|
||||
line = ((copies.get(refs.ref_id(ref)) or {}).get("담은로직") or [None])[0]
|
||||
if not isinstance(line, dict) or not line.get("로직"):
|
||||
continue
|
||||
got = {
|
||||
"code": str(line["로직"]),
|
||||
"given": line.get("입력값") or {},
|
||||
"rate": line.get("켜쌓기율"),
|
||||
"picks": line.get("고름"),
|
||||
}
|
||||
else:
|
||||
continue
|
||||
out.append({**got, "given": _clean_given(got["given"])})
|
||||
return out
|
||||
|
||||
|
||||
# ── 풀이 ──────────────────────────────────────────────────────────────
|
||||
class _Prices:
|
||||
"""B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값)."""
|
||||
|
||||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||||
self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []}
|
||||
self.machine = {r["key"]: r for r in sheets.get("machine") or []}
|
||||
self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []}
|
||||
|
||||
|
||||
def _unit(code: str) -> tuple[str, Decimal]:
|
||||
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10)."""
|
||||
pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0]
|
||||
return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1))
|
||||
|
||||
|
||||
def _env(whole: Any, row: dict, given: dict) -> dict:
|
||||
"""입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리)."""
|
||||
auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None}
|
||||
env = mf._inputs(row, {**auto, **m01._dec(given or {})})
|
||||
for step in row.get("중간", []):
|
||||
try:
|
||||
value = mf.evaluate(mf.parse(step["식"]), env, whole, 0)
|
||||
except mf.EmptyInput as e:
|
||||
env[step["이름"]] = mf.Blank(str(e))
|
||||
continue
|
||||
if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)):
|
||||
value = Decimal(str(round_half(float(value), 2)))
|
||||
env[step["이름"]] = value
|
||||
return env
|
||||
|
||||
|
||||
def _captured(whole: Any, node: Any, env: dict) -> list[str]:
|
||||
"""식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계."""
|
||||
seen: list[str] = []
|
||||
base = whole.get
|
||||
|
||||
def get(ref: str) -> dict[str, Any]:
|
||||
row = base(ref)
|
||||
key = str(row.get("키") or "")
|
||||
if key.startswith("EQ") and key not in seen:
|
||||
seen.append(key)
|
||||
return row
|
||||
|
||||
whole.get = get
|
||||
try:
|
||||
mf.evaluate(node, env, whole, 1)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표)
|
||||
finally:
|
||||
whole.get = base
|
||||
return seen
|
||||
|
||||
|
||||
def _money(price: float | None, qty: float | None) -> float | None:
|
||||
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
|
||||
return None if price is None or qty is None else rounddown(price * qty, 1)
|
||||
|
||||
|
||||
def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]:
|
||||
"""총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸."""
|
||||
out: dict[str, float | None] = {}
|
||||
for part in PARTS:
|
||||
s = total([ln[part] for ln in lines])
|
||||
out[part] = None if s is None else rounddown(s, 0)
|
||||
return {**out, "sum": total([out[p] for p in PARTS])}
|
||||
|
||||
|
||||
class Solver:
|
||||
"""한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함)."""
|
||||
|
||||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||||
self.prices = _Prices(sheets)
|
||||
self.blocks: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def add(
|
||||
self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0
|
||||
) -> dict[str, Any]:
|
||||
key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks"))
|
||||
if key not in self.blocks:
|
||||
self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저
|
||||
self.blocks[key].update(self._solve(whole, src, depth))
|
||||
if owner and owner not in self.blocks[key]["uses"]:
|
||||
self.blocks[key]["uses"].append(owner)
|
||||
return self.blocks[key]
|
||||
|
||||
def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]:
|
||||
code = src["code"]
|
||||
try:
|
||||
row = whole.logic(code)
|
||||
except mf.FormulaError as e:
|
||||
return self._stop(code, code, f"로직 없음 — {e}")
|
||||
name = str(row.get("이름") or code)
|
||||
if isinstance(row.get("흐름"), dict) or "결과" in row:
|
||||
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
|
||||
unit, times = _unit(code)
|
||||
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
|
||||
lines: list[dict[str, Any]] = []
|
||||
try:
|
||||
env = _env(whole, row, src["given"])
|
||||
with mf.mp.choosing(src.get("picks")):
|
||||
for n, item in enumerate(row.get("호표", [])):
|
||||
lines.append(self._line(whole, item, n, env, times, rate, depth))
|
||||
self._extras(whole, row, env, lines)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return self._stop(code, name, str(e) or type(e).__name__)
|
||||
sums = block_totals(lines)
|
||||
why = next((ln["why"] for ln in lines if ln.get("why")), None)
|
||||
return {
|
||||
"logic": code,
|
||||
"name": name,
|
||||
"spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()),
|
||||
"unit": unit,
|
||||
"source": row.get("출처") or "",
|
||||
"lines": lines,
|
||||
**sums,
|
||||
"why": why,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
|
||||
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
|
||||
**dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip
|
||||
|
||||
def _blank(self, item: dict, why: str) -> dict[str, Any]:
|
||||
part = COST_PART.get(item.get("비목"), "material")
|
||||
return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None,
|
||||
"unit": item.get("단위", ""), "ref": None, "price": {},
|
||||
**{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip
|
||||
|
||||
def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]:
|
||||
try:
|
||||
raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0)
|
||||
except mf.EmptyInput as e:
|
||||
return self._blank(item, str(e))
|
||||
qty = Decimal(str(raw)) / times * (rate or 1)
|
||||
q = float(qty)
|
||||
base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")}
|
||||
zero = dict.fromkeys(PARTS, 0.0)
|
||||
kind = item.get("종류")
|
||||
if kind == "로직":
|
||||
return self._logic_line(whole, item, env, q, base, zero, depth)
|
||||
if kind == "인력":
|
||||
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
|
||||
price = self.prices.labor.get(key)
|
||||
why = None if price is not None else f"노무비목록표에 없음 「{key}」"
|
||||
return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key},
|
||||
"price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip
|
||||
ref = (
|
||||
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
|
||||
if isinstance(item["요소"], dict)
|
||||
else mf.fill(item["요소"], env)
|
||||
)
|
||||
_price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n)
|
||||
goods = (extra or {}).get("품목") or {}
|
||||
key = str(
|
||||
goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "")
|
||||
)
|
||||
part = COST_PART.get(item.get("비목"), "material")
|
||||
price = self.prices.material.get(key)
|
||||
if q == 0:
|
||||
price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0
|
||||
why = None if price is not None else f"자재단가대비표에 없음 「{key}」"
|
||||
spec = goods.get("규격") or ""
|
||||
ref = {"tab": "compare", "key": key}
|
||||
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
|
||||
"price": {part: price}, part: _money(price, q), "why": why} # fmt: skip
|
||||
|
||||
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
|
||||
node = mf.parse(item["요소"])
|
||||
called = node[1] if node and node[0] == "logic" else None
|
||||
try:
|
||||
target = whole.logic(called) if called else None
|
||||
except mf.FormulaError:
|
||||
target = None
|
||||
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||||
keys = _captured(whole, node, env)
|
||||
m = self.prices.machine.get(keys[0]) if keys else None
|
||||
price = {p: num((m or {}).get(p)) for p in PARTS}
|
||||
why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」"
|
||||
amounts = {p: _money(price[p], q) if m else None for p in PARTS}
|
||||
ref = {"tab": "machine", "key": keys[0] if keys else ""}
|
||||
return {**base, "kind": "machine", "ref": ref,
|
||||
"price": price, **amounts, "why": why} # fmt: skip
|
||||
if not target or depth >= DEPTH:
|
||||
return {**self._blank(item, "하위 로직을 못 펼침"), **base}
|
||||
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
|
||||
sub = self.add(whole, {"code": called, "given": _clean_given(given)}, None, depth + 1)
|
||||
price = {p: sub.get(p) for p in PARTS}
|
||||
why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸"
|
||||
return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]},
|
||||
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
|
||||
|
||||
def _extras(self, whole, row, env, lines) -> None:
|
||||
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별."""
|
||||
for extra in row.get("덧줄", []):
|
||||
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
|
||||
part = COST_PART.get(extra.get("비목"), "material")
|
||||
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
|
||||
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
|
||||
if any(v is None for v in sums.values()):
|
||||
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
|
||||
continue
|
||||
env.update({k: Decimal(str(v)) for k, v in sums.items()})
|
||||
env["줄"] = {
|
||||
ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS)))
|
||||
for ln in lines
|
||||
if ln["name"]
|
||||
}
|
||||
try:
|
||||
value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0))
|
||||
except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e:
|
||||
lines.append({**line, part: None, "price": {}, "why": str(e)})
|
||||
continue
|
||||
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
|
||||
|
||||
|
||||
# ── 모음 ──────────────────────────────────────────────────────────────
|
||||
def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
|
||||
solver = Solver(sheets)
|
||||
for owner, folder in entries:
|
||||
rows = rows_of(folder)
|
||||
if not rows:
|
||||
continue
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
for src in rows:
|
||||
solver.add(whole, src, owner)
|
||||
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
|
||||
listing = [
|
||||
{k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")}
|
||||
for b in blocks
|
||||
]
|
||||
gaps = [
|
||||
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
|
||||
for b in blocks
|
||||
if b["sum"] is None
|
||||
]
|
||||
return {
|
||||
"sg": blocks,
|
||||
"sg_list": listing,
|
||||
"sg_gaps": gaps,
|
||||
"sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]),
|
||||
}
|
||||
|
||||
|
||||
def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""연결된 사본 전부(구조물 id 차례)의 산근."""
|
||||
base = structures_dir(project_root)
|
||||
entries = [
|
||||
(owner, base / linked["폴더"])
|
||||
for owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
return collect(entries, sheets)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표
|
||||
(서버 단독).
|
||||
|
||||
역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량.
|
||||
호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}`
|
||||
(호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음
|
||||
4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음)
|
||||
끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) ·
|
||||
중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합.
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠).
|
||||
결과는 GET 때 셈(저장 안 함) · 조작 중엔 다시 안 셈([저장] 뒤 서버 정본).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half
|
||||
|
||||
#: 겹 호표 깊이 한도(엑셀 최대 대가 → 대가 → 산근 → 중기 = 4) — 돌고 도는 구성 막음
|
||||
MAX_DEPTH = 8
|
||||
KINDS = ("material", "labor", "expense", "machine")
|
||||
|
||||
|
||||
def expand(
|
||||
lines_of: dict[str, Any], key: str, qty: float, out: dict[str, dict[str, float]], depth: int = 0
|
||||
) -> None:
|
||||
"""호표 하나 × 수량 → 자원 총수량에 더함(겹 호표는 끝까지 · 깊이 한도 넘으면 멈춤)."""
|
||||
if depth > MAX_DEPTH:
|
||||
return
|
||||
for line in (lines_of.get(key) or {}).get("lines") or []:
|
||||
q = num(line.get("qty"))
|
||||
if q is None:
|
||||
continue
|
||||
kind = line.get("kind")
|
||||
if kind == "hopyo":
|
||||
expand(lines_of, str(line.get("key")), qty * q, out, depth + 1)
|
||||
elif kind in KINDS:
|
||||
bucket = out.setdefault(kind, {})
|
||||
bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q
|
||||
|
||||
|
||||
def totals(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, dict[str, float]]:
|
||||
"""설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함)."""
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for row in (sheets.get("bill") or {}).get("rows") or []:
|
||||
qty = num(row.get("qty"))
|
||||
ref = row.get("ref") or {}
|
||||
if row.get("kind") != "item" or qty is None or not ref.get("key"):
|
||||
continue
|
||||
if ref.get("tab") == "material":
|
||||
bucket = out.setdefault("material", {})
|
||||
bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty
|
||||
else:
|
||||
expand(lines_of, str(ref["key"]), qty, out)
|
||||
return out
|
||||
|
||||
|
||||
def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
return {row["key"]: row for row in rows}
|
||||
|
||||
|
||||
def _resource_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]], unit_default: str):
|
||||
rows = []
|
||||
for no, (key, qty) in enumerate(used.items(), 1):
|
||||
src = listed.get(key) or {}
|
||||
q = round_half(qty, 2)
|
||||
price = num(src.get("price"))
|
||||
rows.append(
|
||||
{
|
||||
"no": no,
|
||||
"key": key,
|
||||
"name": src.get("name") or key,
|
||||
"spec": src.get("spec") or "",
|
||||
"unit": src.get("unit") or unit_default,
|
||||
"qty": q,
|
||||
"price": price,
|
||||
"amount": round_half(q * price, 0) if price is not None else None,
|
||||
"note": "" if src else "목록표에 없음",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]):
|
||||
rows = []
|
||||
for no, (key, qty) in enumerate(used.items(), 1):
|
||||
src = listed.get(key) or {}
|
||||
q = round_half(qty, 2)
|
||||
parts = {}
|
||||
for part in ("labor", "material", "expense"):
|
||||
unit = num(src.get(part))
|
||||
parts[part] = round_half(q * unit, 0) if unit is not None else None
|
||||
known = all(v is not None for v in parts.values())
|
||||
rows.append(
|
||||
{
|
||||
"no": no,
|
||||
"key": key,
|
||||
"name": src.get("name") or key,
|
||||
"spec": src.get("spec") or "",
|
||||
"unit": "시간",
|
||||
"qty": q,
|
||||
**parts,
|
||||
"sum": parts["labor"] + parts["material"] + parts["expense"] if known else None,
|
||||
"note": "" if src else "목록표에 없음",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]:
|
||||
"""집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서)."""
|
||||
used = totals(sheets, lines_of)
|
||||
return {
|
||||
"sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""),
|
||||
"sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"),
|
||||
"sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""),
|
||||
"sum_machine": _machine_sheet(used.get("machine", {}), _list(sheets["machine"])),
|
||||
}
|
||||
|
||||
|
||||
def hopyo_lines(project_root: Any) -> dict[str, Any]:
|
||||
"""호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음."""
|
||||
return {}
|
||||
@@ -25,7 +25,9 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { computeLabor } from "./B09_Estimation_Calc_Labor";
|
||||
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
|
||||
import { computeTotal } from "./B09_Estimation_Calc_Total";
|
||||
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
|
||||
import {
|
||||
buildCheck,
|
||||
@@ -45,20 +47,27 @@ import {
|
||||
buildMachineBlocks,
|
||||
buildMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
|
||||
import {
|
||||
buildSumExpense,
|
||||
buildSumLabor,
|
||||
buildSumMachine,
|
||||
buildSumMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const LATER: Record<string, string> = {
|
||||
sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김",
|
||||
sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김",
|
||||
};
|
||||
const LATER: Record<string, string> = {};
|
||||
|
||||
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
check: buildCheck,
|
||||
dg_list: buildDaegaList,
|
||||
dg: buildDaegaBlocks,
|
||||
bill: buildBill,
|
||||
basis: buildBasis,
|
||||
machine: buildMachine,
|
||||
machine_blocks: buildMachineBlocks,
|
||||
@@ -66,6 +75,14 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
labor: buildLabor,
|
||||
expense: buildExpense,
|
||||
compare: buildCompare,
|
||||
cost: buildCost,
|
||||
total: buildTotal,
|
||||
sum_material: buildSumMaterial,
|
||||
sum_labor: buildSumLabor,
|
||||
sum_expense: buildSumExpense,
|
||||
sum_machine: buildSumMachine,
|
||||
sg_list: buildSgList,
|
||||
sg: buildSg,
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
@@ -89,10 +106,30 @@ function note(textValue: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
|
||||
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */
|
||||
const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
const base = computeBase(payload.inputs, user);
|
||||
return { ...base, ...computeLabor(payload.inputs.daega, base) };
|
||||
const server: Row = payload.sheets || {};
|
||||
const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []]));
|
||||
// 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다
|
||||
const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r]));
|
||||
const labor = computeLabor(
|
||||
payload.inputs.daega,
|
||||
{ ...computeBase(payload.inputs, user), gaps: [] },
|
||||
sg,
|
||||
);
|
||||
const base = computeEstimation(payload.inputs, user, (owner: string) => daegaPrice(labor, owner));
|
||||
const { gaps: daegaGaps, ...daega } = labor;
|
||||
return {
|
||||
...base,
|
||||
...kept,
|
||||
...daega,
|
||||
gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps],
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
total: computeTotal(base.bill?.sums, user.estimate),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderB09Estimation(root: HTMLElement): void {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill.ts
|
||||
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
*
|
||||
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
...r,
|
||||
name: r.kind === "title" ? r.name : ` ${r.name}`,
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`price_${key}`, "단가", [label]),
|
||||
money(key, "금액", [label]),
|
||||
];
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 명칭", 220),
|
||||
text("spec", "규격", 160),
|
||||
qty("qty", "수량", 3),
|
||||
text("unit", "단위", 56),
|
||||
...pair("sum", "합계"),
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
text("note", "비고", 200),
|
||||
],
|
||||
rows,
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => key === "qty" && rows[i]?.key === "fee",
|
||||
onEdit: (i, key, value) => {
|
||||
if (key !== "qty" || rows[i]?.key !== "fee") return;
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
link: (i) => {
|
||||
const ref = rows[i]?.ref as Row | null | undefined;
|
||||
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun.ts
|
||||
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
|
||||
*
|
||||
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
|
||||
* 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계
|
||||
* 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
type Link,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const PART_NAME: Record<string, string> = {
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
expense: "경 비",
|
||||
};
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
|
||||
const notes = (sheets: Row): string =>
|
||||
((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join("");
|
||||
|
||||
export const buildSgList: TabBuild = ({ sheets }): TabTable => {
|
||||
const rows: DataRow[] = ((sheets.sg_list as Row[]) || []).map((r) => ({
|
||||
...r,
|
||||
note: `쓰는 구조물 ${(r.uses || []).length}`,
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 220),
|
||||
text("spec", "규격(입력값)", 200),
|
||||
text("unit", "단위", 56),
|
||||
money("sum", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
text("note", "비고", 110),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
notes(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
const LINE_TAB: Record<string, TabId> = {
|
||||
labor: "labor",
|
||||
machine: "machine",
|
||||
compare: "compare",
|
||||
sg: "sg",
|
||||
};
|
||||
|
||||
export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
const rows: DataRow[] = [];
|
||||
const links: Array<Link | null> = [];
|
||||
const push = (row: DataRow, link: Link | null = null): void => {
|
||||
rows.push(row);
|
||||
links.push(link);
|
||||
};
|
||||
for (const b of (sheets.sg as Row[]) || []) {
|
||||
push({
|
||||
key: b.key,
|
||||
name: `제 ${b.no} 호표 ${b.name}`,
|
||||
spec: b.spec,
|
||||
unit: b.unit,
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` });
|
||||
(b.lines as Row[]).forEach((ln, n) => {
|
||||
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
|
||||
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄
|
||||
const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null];
|
||||
for (const part of parts) {
|
||||
const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0);
|
||||
const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null);
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
|
||||
name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`,
|
||||
spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
price: price as number | null,
|
||||
sum: PARTS.some((p) => at(p) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
|
||||
labor: at("labor"),
|
||||
material: at("material"),
|
||||
expense: at("expense"),
|
||||
},
|
||||
link,
|
||||
);
|
||||
}
|
||||
});
|
||||
push({
|
||||
key: `${b.key}|총계`,
|
||||
name: " 총 계",
|
||||
spec: b.why ?? "",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
});
|
||||
}
|
||||
if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음";
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 산출내역", 260),
|
||||
text("spec", "규격 · 까닭", 180),
|
||||
qty("qty", "수량"),
|
||||
text("unit", "단위", 56),
|
||||
money("price", "단가", undefined, 1),
|
||||
money("sum", "합계", undefined, 1),
|
||||
money("labor", "노무비", undefined, 1),
|
||||
money("material", "재료비", undefined, 1),
|
||||
money("expense", "경비", undefined, 1),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
"수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Summary.ts
|
||||
* B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표(실무 엑셀 꼴).
|
||||
*
|
||||
* 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본
|
||||
* 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const HINT =
|
||||
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
|
||||
|
||||
function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
|
||||
return ({ payload }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
|
||||
const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 140),
|
||||
qty("qty", "수량", 2),
|
||||
text("unit", "단위", 56),
|
||||
money("price", unitPrice),
|
||||
money("amount", "금액"),
|
||||
text("note", "비고", 120),
|
||||
],
|
||||
rows,
|
||||
total: rows.length ? { name: "계", amount: sum } : null,
|
||||
frozen: 2,
|
||||
hint: HINT,
|
||||
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const buildSumMaterial = resourceTab("sum_material", "material", "단가");
|
||||
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
|
||||
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가");
|
||||
|
||||
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
|
||||
const add = (key: string) =>
|
||||
rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 120),
|
||||
qty("qty", "수량", 2),
|
||||
text("unit", "단위", 56),
|
||||
money("sum", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
text("note", "비고", 120),
|
||||
],
|
||||
rows,
|
||||
total: rows.length
|
||||
? {
|
||||
name: "계",
|
||||
sum: add("sum"),
|
||||
labor: add("labor"),
|
||||
material: add("material"),
|
||||
expense: add("expense"),
|
||||
}
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`,
|
||||
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Total.ts
|
||||
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
|
||||
*
|
||||
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
|
||||
* 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 ·
|
||||
* 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸)
|
||||
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabContext,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US"));
|
||||
|
||||
/** 손입력 칸 — 줄 키 → user.estimate 칸 */
|
||||
const HAND: Record<string, [string, string]> = {
|
||||
safety: ["safety_base", "기초액(원)"],
|
||||
profit: ["profit_adjust", "이윤 보정(원 · 뺌)"],
|
||||
vat: ["vat_adjust", "부가세 조정(원)"],
|
||||
supply: ["procurement_fee_pct", "조달수수료율(%)"],
|
||||
indirect_material: ["indirect_material", "간접재료비(원)"],
|
||||
scrap: ["scrap", "작업설 부산물(원)"],
|
||||
};
|
||||
const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key));
|
||||
|
||||
/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */
|
||||
function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void {
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
const estimate: Row = { ...(ctx.user.estimate || {}) };
|
||||
if (column === "rate" && RATE_KEYS.has(rowKey)) {
|
||||
const rates: Row = { ...(estimate.rates || {}) };
|
||||
if (v === null) delete rates[rowKey];
|
||||
else rates[rowKey] = v;
|
||||
estimate.rates = rates;
|
||||
} else if (HAND[rowKey]) {
|
||||
const field = HAND[rowKey][0];
|
||||
if (v === null) delete estimate[field];
|
||||
else estimate[field] = v;
|
||||
} else return;
|
||||
ctx.setUser({ ...ctx.user, estimate });
|
||||
}
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
}
|
||||
|
||||
export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
const est: Row = ctx.user.estimate || {};
|
||||
const rates: Row = est.rates || {};
|
||||
const cand: Row = ctx.payload.rate_candidates || {};
|
||||
const line: Row = t.lines || {};
|
||||
const sub: Row = t.subtotals || {};
|
||||
const rate = (key: string): Num => (typeof rates[key] === "number" ? rates[key] : null);
|
||||
const hand = (key: string): Num => {
|
||||
const v = est[HAND[key][0]];
|
||||
return typeof v === "number" ? v : null;
|
||||
};
|
||||
const row = (
|
||||
key: string,
|
||||
part: string,
|
||||
name: string,
|
||||
amount: Num,
|
||||
note = "",
|
||||
extra: Partial<DataRow> = {},
|
||||
): DataRow => ({
|
||||
key,
|
||||
part,
|
||||
name,
|
||||
amount,
|
||||
note,
|
||||
rate: null,
|
||||
hand: null,
|
||||
candidate: "",
|
||||
...extra,
|
||||
});
|
||||
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
|
||||
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), {
|
||||
rate: rate(key),
|
||||
candidate: cand[rt] ?? rt,
|
||||
...(HAND[key] ? { hand: hand(key) } : {}),
|
||||
});
|
||||
const safety: Row = t.safety || {};
|
||||
const supply: Row = t.supply || {};
|
||||
const sums: Row = t.sums || {};
|
||||
const rows: DataRow[] = [
|
||||
row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", {
|
||||
labor: sums.labor ?? null,
|
||||
material: sums.material ?? null,
|
||||
expense: sums.expense ?? null,
|
||||
}),
|
||||
...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)),
|
||||
...CURRENT_ONLY.map(([key, name, rt]) =>
|
||||
row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", {
|
||||
candidate: cand[rt] ?? rt,
|
||||
}),
|
||||
),
|
||||
row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null),
|
||||
rateRow(TAIL_LINES[0], RATE_LINES.length + 1),
|
||||
row("da", "다.", "소계", sub.da ?? null),
|
||||
rateRow(TAIL_LINES[1], RATE_LINES.length + 2),
|
||||
row("supply_price", "라.", "공급가액", sub.supply_price ?? null),
|
||||
rateRow(TAIL_LINES[2], RATE_LINES.length + 3),
|
||||
row("contract", "마.", "도급공사비", sub.contract ?? null),
|
||||
row(
|
||||
"supply",
|
||||
"",
|
||||
`${RATE_LINES.length + 4}. 관급자재대`,
|
||||
line.supply ?? null,
|
||||
`순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`,
|
||||
{ hand: hand("supply") },
|
||||
),
|
||||
row("grand", "바.", "총공사비", sub.grand ?? null),
|
||||
];
|
||||
const safetyRow = rows.find((r) => r.key === "safety");
|
||||
if (safetyRow && safety.a !== null && safety.a !== undefined)
|
||||
safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`;
|
||||
const profitRow = rows.find((r) => r.key === "profit");
|
||||
if (profitRow && typeof t.profit_raw === "number")
|
||||
profitRow.note = `${profitRow.note} · 보정 전 ${won(t.profit_raw)}`;
|
||||
return {
|
||||
columns: [
|
||||
text("part", "공종", 48),
|
||||
text("name", "명칭", 210),
|
||||
money("amount", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
qty("rate", "요율(%)", 5),
|
||||
money("hand", "손입력", undefined, 2),
|
||||
text("note", "산식", 320),
|
||||
text("candidate", "요율 후보(확정 아님)", 260),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " +
|
||||
"손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
|
||||
editable: (i, key) =>
|
||||
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
|
||||
(key === "hand" && String(rows[i]?.key) in HAND),
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
};
|
||||
};
|
||||
|
||||
/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */
|
||||
const COST_ROWS: Array<[string, string, string, string, string?]> = [
|
||||
["direct_material", "순공사원가", "재료비", "직접재료비", "material"],
|
||||
["indirect_material", "순공사원가", "재료비", "간접재료비"],
|
||||
["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"],
|
||||
["material", "순공사원가", "재료비", "소계", "direct"],
|
||||
["direct_labor", "순공사원가", "노무비", "직접노무비", "direct"],
|
||||
["indirect_labor", "순공사원가", "노무비", "간접노무비", "indirect_labor"],
|
||||
["labor", "순공사원가", "노무비", "소계", "direct"],
|
||||
["expense", "순공사원가", "경비", "산출경비", "direct"],
|
||||
...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(
|
||||
([key, name]) =>
|
||||
[key, "순공사원가", "경비", name, key] as [string, string, string, string, string],
|
||||
),
|
||||
["expense_sum", "순공사원가", "경비", "소계", "net_cost"],
|
||||
["overhead", "일반관리비", "", "", "overhead"],
|
||||
["profit", "이윤", "", "", "profit"],
|
||||
["supply_price", "총원가", "", "", "supply_price"],
|
||||
["vat", "부가가치세", "", "", "vat"],
|
||||
["contract", "도급공사비", "", "", "contract"],
|
||||
["supply", "관급자재대", "", "", "supply"],
|
||||
["grand", "총공사비", "", "", "grand"],
|
||||
];
|
||||
|
||||
export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
const cost: Row = t.cost || {};
|
||||
const share: Row = t.share || {};
|
||||
const rates: Row = (ctx.user.estimate || {}).rates || {};
|
||||
const formula: Record<string, string> = Object.fromEntries(
|
||||
[...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [
|
||||
key,
|
||||
rateText(f, typeof rates[key] === "number" ? rates[key] : null),
|
||||
]),
|
||||
);
|
||||
const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({
|
||||
key,
|
||||
group,
|
||||
part,
|
||||
name,
|
||||
amount: cost[key] ?? null,
|
||||
share: share[key] ?? null,
|
||||
note: formula[key] ?? "",
|
||||
link: link ?? "",
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
text("group", "구분", 96),
|
||||
text("part", "비목", 72),
|
||||
text("name", "세목", 180),
|
||||
money("amount", "금액"),
|
||||
qty("share", "구성비(%)", 2),
|
||||
text("note", "비고", 320),
|
||||
],
|
||||
rows,
|
||||
frozen: 3,
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
link: (i) => {
|
||||
const target = String(rows[i]?.link || "");
|
||||
return target ? { tab: "total", key: target } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
/* 130-1 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기)을
|
||||
/* 130-1 · 140-2 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기 ·
|
||||
* 드롭다운 따라가기 · 줄 끌어 순서)을
|
||||
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
|
||||
* 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜.
|
||||
*/
|
||||
@@ -52,6 +53,12 @@ class FakeNode {
|
||||
click() {
|
||||
(this.listeners.click || []).forEach((handler) => handler({}));
|
||||
}
|
||||
fire(type, event = {}) {
|
||||
(this.listeners[type] || []).forEach((handler) => handler({ preventDefault() {}, ...event }));
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { top: 0, height: 20, left: 0, width: 100 };
|
||||
}
|
||||
/** `.a` · `.a .b`(자손) 만. */
|
||||
querySelectorAll(selector) {
|
||||
const parts = selector
|
||||
@@ -204,6 +211,12 @@ const API = {
|
||||
return copy();
|
||||
},
|
||||
savePlanMarks: async () => copy(),
|
||||
reorderPlanKinds: async (_, ids) => {
|
||||
calls.push(["reorder", ids]);
|
||||
state.kinds = ids.map((id) => state.kinds.find((k) => k.id === id));
|
||||
confirmed.delete("plan_k_route"); // 번호 바뀐 장 확정 풂(서버 몫 흉내)
|
||||
return copy();
|
||||
},
|
||||
invalidateDesignDrawing: async (_, id) => {
|
||||
calls.push(["invalidate", id]);
|
||||
confirmed.delete(id);
|
||||
@@ -258,14 +271,10 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
|
||||
await settle();
|
||||
const modal = modals[0];
|
||||
const cad = cads[0];
|
||||
const kindRows = () =>
|
||||
modal.body
|
||||
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
|
||||
.map((row) => row.dataset.planKind);
|
||||
const rows = () => modal.body.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row");
|
||||
const kindRows = () => rows().map((row) => row.dataset.planKind);
|
||||
const sheetIds = () =>
|
||||
modal.body
|
||||
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
|
||||
.map((b) => b.dataset.drawingId);
|
||||
modal.body.querySelectorAll(".b07-plan-manage__sheet").map((b) => b.dataset.drawingId);
|
||||
const kindPick = () => selects.filter((s) => s.label === "종류").at(-1);
|
||||
const action = () =>
|
||||
all(modal.body).find(
|
||||
@@ -302,16 +311,17 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
|
||||
await settle();
|
||||
out.update = {
|
||||
call: calls.find((c) => c[0] === "update"),
|
||||
rowText: modal.body
|
||||
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
|
||||
.find((row) => row.dataset.planKind === "k_new").textContent,
|
||||
rowText: rows().find((row) => row.dataset.planKind === "k_new").textContent,
|
||||
};
|
||||
|
||||
// 지우기 — 그 줄의 ×
|
||||
const row = modal.body
|
||||
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
|
||||
.find((r) => r.dataset.planKind === "k_new");
|
||||
byText(row, "BUTTON", "×")[0].click();
|
||||
// 지우기 — 드롭다운에서 그 종류를 고르고 폼 안 [지우기]
|
||||
kindPick().change("k_new");
|
||||
out.removeHiddenForNew = (() => {
|
||||
kindPick().change("");
|
||||
return byText(modal.body, "BUTTON", "지우기")[0].hidden;
|
||||
})();
|
||||
kindPick().change("k_new");
|
||||
byText(modal.body, "BUTTON", "지우기")[0].click();
|
||||
await settle();
|
||||
await settle();
|
||||
out.remove = {
|
||||
@@ -320,10 +330,10 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
|
||||
confirms: confirms.length,
|
||||
};
|
||||
|
||||
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기])
|
||||
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1)
|
||||
const pickSheet = async (id) => {
|
||||
modal.body
|
||||
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
|
||||
.querySelectorAll(".b07-plan-manage__sheet")
|
||||
.find((b) => b.dataset.drawingId === id)
|
||||
.click();
|
||||
await settle();
|
||||
@@ -334,19 +344,49 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
|
||||
preview: cad.loads.at(-1).drawing.entities[0].id,
|
||||
head: head().textContent,
|
||||
active: modal.body
|
||||
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
|
||||
.querySelectorAll(".b07-plan-manage__sheet")
|
||||
.filter((b) => b.dataset.active === "true")
|
||||
.map((b) => b.dataset.drawingId),
|
||||
sheetTexts: rows()[0]
|
||||
.querySelectorAll(".b07-plan-manage__sheet")
|
||||
.map((b) => b.textContent),
|
||||
dropdown: kindPick().select.value,
|
||||
activeRow: rows()
|
||||
.filter((r) => r.dataset.active === "true")
|
||||
.map((r) => r.dataset.planKind),
|
||||
};
|
||||
await pickSheet("plan_k_route");
|
||||
out.routeHead = head().textContent;
|
||||
byText(head(), "BUTTON", "확정 풀기")[0].click();
|
||||
out.routeDropdown = kindPick().select.value;
|
||||
|
||||
// 드롭다운으로 종류 고름 → 그 종류 첫 장 미리보기 + 줄 강조
|
||||
kindPick().change("k_terrain");
|
||||
await settle();
|
||||
await settle();
|
||||
out.release = {
|
||||
call: calls.find((c) => c[0] === "invalidate"),
|
||||
head: head().textContent,
|
||||
out.dropdownPick = {
|
||||
preview: cad.loads.at(-1).drawing.entities[0].id,
|
||||
activeRow: rows()
|
||||
.filter((r) => r.dataset.active === "true")
|
||||
.map((r) => r.dataset.planKind),
|
||||
};
|
||||
|
||||
// 줄 끌어 놓기 — 제자리면 부르지 않음 · 「노선배치도」를 「지형」 위 반에 놓으면 앞으로(놓는 즉시)
|
||||
const dragTo = async (fromId, toId, clientY) => {
|
||||
const from = rows().find((r) => r.dataset.planKind === fromId);
|
||||
const to = rows().find((r) => r.dataset.planKind === toId);
|
||||
from.fire("dragstart", {});
|
||||
to.fire("dragover", { clientY });
|
||||
to.fire("drop", {});
|
||||
from.fire("dragend");
|
||||
await settle();
|
||||
await settle();
|
||||
};
|
||||
await dragTo("k_route", "k_terrain", 15); // 아래 반 = 뒤 = 제자리
|
||||
out.dragSame = calls.filter((c) => c[0] === "reorder").length;
|
||||
await dragTo("k_route", "k_terrain", 5);
|
||||
out.drag = {
|
||||
call: calls.find((c) => c[0] === "reorder"),
|
||||
kinds: kindRows(),
|
||||
sheets: sheetIds(),
|
||||
};
|
||||
|
||||
// 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤
|
||||
@@ -359,7 +399,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
|
||||
await settle();
|
||||
const modal2 = modals[1];
|
||||
modal2.body
|
||||
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
|
||||
.querySelectorAll(".b07-plan-manage__sheet")
|
||||
.find((b) => b.dataset.drawingId === "plan_k_route")
|
||||
.click();
|
||||
await settle();
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
못박는 것
|
||||
- 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음.
|
||||
- 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용).
|
||||
- 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음.
|
||||
- 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
|
||||
- 단추 → 공용 모달(큰 창) · 안에 [종류 ▼] 폼 · 평면도 장 블록(종류마다 한 줄 · 작은 장 단추) · 대피소 · 사토장 자리 ·
|
||||
첫 장 미리보기(보기 전용).
|
||||
- 종류 추가 · 업데이트(축척 포함) · 지우기(폼 안) = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음.
|
||||
- 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2).
|
||||
- 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
|
||||
|
||||
Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
@@ -54,6 +56,7 @@ def test_첫_장_미리보기는_보기_전용(out):
|
||||
assert out["open"]["preview"] == "plan_k_terrain"
|
||||
extra = out["open"]["extra"]
|
||||
assert extra["readOnly"] is True and extra["viewOnly"] is True
|
||||
assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음)
|
||||
assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침
|
||||
|
||||
|
||||
@@ -66,10 +69,11 @@ def test_종류_추가(out):
|
||||
def test_종류_업데이트(out):
|
||||
assert out["updateLabel"] == "업데이트"
|
||||
assert out["update"]["call"] == ["update", "k_new", "sub6임시2", [], 1000]
|
||||
assert out["update"]["rowText"].startswith("sub6임시2 · 1장")
|
||||
assert out["update"]["rowText"] == "≡sub6임시2" # 한 장이면 이름이 곧 장 단추
|
||||
|
||||
|
||||
def test_종류_지우기(out):
|
||||
assert out["removeHiddenForNew"] is True # 「새 종류」엔 [지우기] 없음
|
||||
assert out["remove"]["call"] == ["delete", "k_new"]
|
||||
assert out["remove"]["confirms"] == 1
|
||||
assert out["remove"]["kinds"] == ["k_terrain", "k_route"]
|
||||
@@ -80,14 +84,27 @@ def test_미리보기_장_바꾸기(out):
|
||||
assert s2["preview"] == "plan_k_terrain_2"
|
||||
assert s2["active"] == ["plan_k_terrain_2"]
|
||||
assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"]
|
||||
assert s2["sheetTexts"] == ["1", "2"] # 여러 장 = 작은 장 단추
|
||||
assert s2["dropdown"] == "k_terrain" and s2["activeRow"] == ["k_terrain"]
|
||||
assert out["routeDropdown"] == "k_route"
|
||||
|
||||
|
||||
def test_확정이면_확정_풀기(out):
|
||||
assert "확정 풀기" in out["routeHead"]
|
||||
rel = out["release"]
|
||||
assert rel["call"] == ["invalidate", "plan_k_route"]
|
||||
assert "확정 풀기" not in rel["head"]
|
||||
assert rel["preview"] == "plan_k_route" # 보던 장 그대로
|
||||
def test_드롭다운이면_그_종류_첫_장(out):
|
||||
assert out["dropdownPick"] == {"preview": "plan_k_terrain", "activeRow": ["k_terrain"]}
|
||||
|
||||
|
||||
def test_줄_끌어_놓기는_종류_순서(out):
|
||||
assert out["dragSame"] == 0 # 제자리면 서버 안 부름
|
||||
drag = out["drag"]
|
||||
assert drag["call"] == ["reorder", ["k_route", "k_terrain"]]
|
||||
assert drag["kinds"] == ["k_route", "k_terrain"]
|
||||
assert drag["sheets"] == ["plan_k_route", "plan_k_terrain", "plan_k_terrain_2"]
|
||||
|
||||
|
||||
def test_확정이면_배지만(out):
|
||||
# 140-1 확정 풀기는 메인 [수정] — 모달엔 배지 · [CAD 에서 열기]만
|
||||
assert "확정" in out["routeHead"] and "CAD 에서 열기" in out["routeHead"]
|
||||
assert "확정 풀기" not in out["routeHead"]
|
||||
|
||||
|
||||
def test_닫으면_좌측_다시_읽기_신호(out):
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""140-3 B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4).
|
||||
|
||||
못박는 것
|
||||
- 칸 = 그림칸(usable_area)을 똑같이 나눈 자리 · 차례 = 좌하단 → 위 → 오른쪽 열.
|
||||
- 측점 73개 · 4 × 4 → 16 · 16 · 16 · 16 · 9 · 17번째 측점 = 2장 첫 칸.
|
||||
- 장마다 다른 배치(sheets {장 번호: [열, 행]}) · 기본 바꾸기.
|
||||
- 단면 블록 가운데 = 칸 가운데 · 블록 크기 = 1/100 그대로(section_block_size).
|
||||
- 도각 = 고정 그림칸 — 내용이 작아도 커도 같은 자리.
|
||||
- 배치 저장 · 읽기(깨진 값 거절) · 담긴 측점이 바뀐 확정 장만 풂.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
build_cross_sheet,
|
||||
grid_slots,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_cross_sheet_plan,
|
||||
read_cross_layout,
|
||||
write_cross_layout,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
|
||||
|
||||
|
||||
def _source(chainage: float, slope: float, half: int = 10) -> dict:
|
||||
return {
|
||||
"chainage_m": chainage,
|
||||
"samples": [
|
||||
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x}
|
||||
for x in range(-half, half + 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_grid_slots_bottom_left_up_then_right() -> None:
|
||||
w, h = usable_area()
|
||||
slots = grid_slots(6, 4, 4)
|
||||
cw, ch = w / 4, h / 4
|
||||
assert slots[0] == pytest.approx([-w / 2 + cw / 2, -h / 2 + ch / 2]) # 좌하단
|
||||
assert slots[1] == pytest.approx([-w / 2 + cw / 2, -h / 2 + 1.5 * ch]) # 그 위
|
||||
assert slots[4] == pytest.approx([-w / 2 + 1.5 * cw, -h / 2 + ch / 2]) # 다음 열 맨 아래
|
||||
|
||||
|
||||
def test_73_stations_default_grid_17th_is_sheet_two() -> None:
|
||||
chainages = [i * 20.0 for i in range(73)]
|
||||
sheets = plan_cross_sheets(chainages)
|
||||
assert [len(s["chainages"]) for s in sheets] == [16, 16, 16, 16, 9]
|
||||
assert sheets[1]["chainages"][0] == chainages[16] # 17번째 측점 = 2장 첫 칸
|
||||
assert sheets[1]["number"] == 2 and sheets[1]["grid"] == [4, 4]
|
||||
assert sheets[1]["id"] == "cross_s00320m" # 장 id = 첫 측점
|
||||
|
||||
|
||||
def test_per_sheet_and_default_layout() -> None:
|
||||
chainages = [float(i) for i in range(40)]
|
||||
layout = {"default": [3, 3], "sheets": {"1": [2, 2], "3": [5, 4]}}
|
||||
sheets = plan_cross_sheets(chainages, layout)
|
||||
assert [s["grid"] for s in sheets] == [[2, 2], [3, 3], [5, 4], [3, 3]]
|
||||
assert [len(s["chainages"]) for s in sheets] == [4, 9, 20, 7]
|
||||
|
||||
|
||||
def _frame_bbox(drawing: dict) -> tuple:
|
||||
return entities_bbox([e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID])
|
||||
|
||||
|
||||
def test_block_centered_in_cell_at_scale_and_frame_fixed() -> None:
|
||||
table = {"ground": 100.0, "planned": 100.0}
|
||||
small = [_source(0.0, 0.05, 6)]
|
||||
big = [_source(0.0, 0.6, 15), _source(20.0, 0.2, 8)]
|
||||
drawings = []
|
||||
for sources in (small, big):
|
||||
sheet = plan_cross_sheets([s["chainage_m"] for s in sources])[0]
|
||||
sections = [
|
||||
{"chainage": s["chainage_m"], "source": s, "quantity_table": table, "title": ""}
|
||||
for s in sources
|
||||
]
|
||||
drawing = build_cross_sheet(sheet, sections)
|
||||
for source, slot, placement in zip(
|
||||
sources, sheet["slots"], drawing["cross_placements"], strict=True
|
||||
):
|
||||
left, bottom, right, top = placement["frame"]
|
||||
# 블록 가운데 = 칸 가운데
|
||||
assert (left + right) / 2 == pytest.approx(slot[0])
|
||||
assert (bottom + top) / 2 == pytest.approx(slot[1])
|
||||
# 1/100 그대로 — 블록 크기가 자기 치수 그대로(칸 맞춤으로 줄이지 않음)
|
||||
width, height = section_block_size(source, None, design=None)
|
||||
assert right - left == pytest.approx(width, abs=0.5)
|
||||
drawings.append(drawing)
|
||||
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 같은 자리
|
||||
assert _frame_bbox(drawings[0]) == pytest.approx(_frame_bbox(drawings[1]))
|
||||
|
||||
|
||||
def _project(tmp_path: Path, count: int) -> tuple[Path, Path]:
|
||||
root = tmp_path / "project"
|
||||
cross_dir = root / "B06_Section" / "cross_sections"
|
||||
cross_dir.mkdir(parents=True)
|
||||
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
longitudinal_path.parent.mkdir(parents=True)
|
||||
stations = []
|
||||
for i in range(count):
|
||||
chainage = i * 20
|
||||
stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0})
|
||||
(cross_dir / f"cross_{chainage:05d}m.json").write_text(
|
||||
json.dumps(_source(float(chainage), 0.1)), "utf-8"
|
||||
)
|
||||
longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8")
|
||||
return root, longitudinal_path
|
||||
|
||||
|
||||
def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None:
|
||||
root, longitudinal_path = _project(tmp_path, 20)
|
||||
assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본
|
||||
ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
|
||||
assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4
|
||||
manifest = _read_manifest(root)
|
||||
manifest["drawings"] = {i: {"confirmed": True} for i in ids}
|
||||
_write_manifest(root, manifest)
|
||||
|
||||
# 2장만 3 × 3 — 1장 측점 그대로(확정 유지) · 2장 측점 그대로(4개 < 9칸) → 풀 장 없음
|
||||
assert (
|
||||
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"2": [3, 3]}})
|
||||
== []
|
||||
)
|
||||
assert read_cross_layout(root)["sheets"] == {"2": [3, 3]}
|
||||
|
||||
# 1장 3 × 3 — 1장 9개로 줄고 2장은 다른 측점에서 시작 → 1장 풂(옛 측점 16개 돌려줌)
|
||||
stale = write_cross_layout(
|
||||
root, longitudinal_path, {"default": [4, 4], "sheets": {"1": [3, 3]}}
|
||||
)
|
||||
assert stale[:16] == [i * 20 for i in range(16)]
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
assert drawings["cross_s00000m"]["confirmed"] is False
|
||||
assert drawings["cross_s00320m"]["confirmed"] is False # 사라진 장 id 도 풂
|
||||
new_ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
|
||||
assert new_ids == ["cross_s00000m", "cross_s00180m"]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
write_cross_layout(root, longitudinal_path, {"default": [0, 4], "sheets": {}})
|
||||
with pytest.raises(ValueError):
|
||||
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"x": [2, 2]}})
|
||||
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""141-3 평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 잘라 유역도 해치 · 배지로.
|
||||
|
||||
견본: 유역 1 = 장 안 정사각 · 유역 2 = 장 경계에 걸침(잘림) · 유역 3 = 장 밖(빠짐).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
PIECE_IDS,
|
||||
build_plan_kind_drawing,
|
||||
layer_id,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import PIECES
|
||||
|
||||
BOX = (0.0, 0.0, 100.0, 100.0)
|
||||
BASINS = [
|
||||
{"ring": [(10, 10), (40, 10), (40, 40), (10, 40), (10, 10)], "props": {"index": 1}},
|
||||
{"ring": [(80, 50), (120, 50), (120, 90), (80, 90), (80, 50)], "props": {"index": 2}},
|
||||
{"ring": [(300, 300), (320, 300), (320, 320), (300, 300)], "props": {"index": 3}},
|
||||
]
|
||||
|
||||
|
||||
def _pieces(monkeypatch) -> dict:
|
||||
monkeypatch.setattr(basin, "metric_basins", lambda _context: BASINS)
|
||||
return basin.plan_basin_pieces(SimpleNamespace(), BOX)
|
||||
|
||||
|
||||
def test_장_범위로_자르고_번호는_유역도와_같다(monkeypatch):
|
||||
pieces = _pieces(monkeypatch)
|
||||
rings = pieces["basin"]["items"]
|
||||
assert [item["number"] for item in rings] == [1, 2] # 장 밖 3 은 빠짐
|
||||
clipped = rings[1]["ring"]
|
||||
assert max(x for x, _y in clipped) == 100.0 # 장 경계에서 잘림
|
||||
numbers = {item["number"]: item["center"] for item in pieces["basin_no"]["items"]}
|
||||
assert set(numbers) == {1, 2}
|
||||
x, y = numbers[2]
|
||||
assert 80 <= x <= 100 and 50 <= y <= 90 # 잘린 조각 안
|
||||
|
||||
|
||||
def test_유역_저장본_없으면_빈_조각(monkeypatch):
|
||||
def missing(_context):
|
||||
raise FileNotFoundError
|
||||
|
||||
monkeypatch.setattr(basin, "metric_basins", missing)
|
||||
assert basin.plan_basin_pieces(SimpleNamespace(), BOX) == {
|
||||
"basin": {"items": []},
|
||||
"basin_no": {"items": []},
|
||||
}
|
||||
|
||||
|
||||
def test_어느_평면도에나_조각_체크(monkeypatch):
|
||||
assert {"basin", "basin_no"} <= set(PIECE_IDS)
|
||||
assert ("basin", "배수유역 경계", False) in PIECES and (
|
||||
"basin_no",
|
||||
"유역 번호",
|
||||
False,
|
||||
) in PIECES
|
||||
drawing = build_plan_kind_drawing("plan_x", "평면도(x)", BOX, _pieces(monkeypatch), 1200)
|
||||
hatches = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin")]
|
||||
assert [e["type"] for e in hatches].count("Hatch") == 2
|
||||
badges = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin_no")]
|
||||
assert sorted(e["shapeData"]["label"] for e in badges if e["type"] == "Text") == ["1", "2"]
|
||||
assert len({e["id"] for e in drawing["entities"]}) == len(drawing["entities"])
|
||||
names = {layer["id"]: layer["name"] for layer in drawing["layers"]}
|
||||
assert names[layer_id("basin")] == "배수유역 경계"
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""141-4 평면도 여러 장 — 노선 따라 자르기 유지 + 맞춤선 · 이웃 구간 흐리게 · 「n/N장」 · 돌린 맞춤 뺌."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_chunk_span_m,
|
||||
plan_chunks,
|
||||
sheet_links,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
SHEET_LAYER,
|
||||
build_plan_kind_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import plan_kind_items
|
||||
|
||||
# 남북으로 곧은 노선 1,200 m · 측점 20 m
|
||||
STATIONS = [(float(c), 0.0, float(c)) for c in range(0, 1201, 20)]
|
||||
|
||||
|
||||
def test_돌린_맞춤_빼기_남북_노선은_그림칸_높이로_나눔():
|
||||
span_w, span_h = _chunk_span_m(1200)
|
||||
assert span_h < 800 < span_w # 옛 규칙 = 돌려 보면 한 장(그림칸 높이를 넘침)
|
||||
chunks = plan_chunks(STATIONS[:41], 1200) # 800 m
|
||||
assert len(chunks) == 2
|
||||
for chunk in chunks:
|
||||
assert chunk["end_m"] - chunk["start_m"] <= span_h
|
||||
|
||||
|
||||
def test_맞춤선_경계_측점_직각_이웃_장_번호():
|
||||
chunks = plan_chunks(STATIONS, 1200)
|
||||
total = len(chunks)
|
||||
assert total == 3
|
||||
middle = chunks[1]
|
||||
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
|
||||
mm = 1000.0 / 1200
|
||||
links = sheet_links(STATIONS, middle, total, box, 20.0, mm, neighbors=True)
|
||||
first, last = links["lines"]
|
||||
start_no, end_no = int(middle["start_m"] // 20), int(middle["end_m"] // 20)
|
||||
assert first["label"] == f"MATCH LINE NO.{start_no} · 1장 이어짐"
|
||||
assert last["label"] == f"MATCH LINE NO.{end_no} · 3장 이어짐"
|
||||
(ax, ay), (bx, by) = first["points"]
|
||||
assert ay == by == middle["start_m"] # 남북 노선에 직각 = 동서
|
||||
assert math.hypot(bx - ax, by - ay) * mm == 60.0 # 종이 60 mm
|
||||
# 이웃 구간 노선 = 도곽 안만(앞 · 뒤 10 m)
|
||||
assert len(links["neighbors"]) == 2
|
||||
for line in links["neighbors"]:
|
||||
assert all(box[1] <= y <= box[3] for _x, y in line)
|
||||
# 첫 장 · 끝 장은 한쪽만 · 중심선 안 고르면 이웃 노선 없음
|
||||
only = sheet_links(STATIONS, chunks[0], total, box, 20.0, mm, neighbors=False)
|
||||
assert [item["label"].endswith("· 2장 이어짐") for item in only["lines"]] == [True]
|
||||
assert only["neighbors"] == []
|
||||
|
||||
|
||||
def test_도면에_맞춤선_층_글은_다른_글과_안_겹침():
|
||||
chunks = plan_chunks(STATIONS, 1200)
|
||||
middle = chunks[1]
|
||||
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
|
||||
mm = 1000.0 / 1200
|
||||
links = sheet_links(STATIONS, middle, 3, box, 20.0, mm, neighbors=True)
|
||||
at = middle["start_m"]
|
||||
pieces = {
|
||||
"sheet_links": links,
|
||||
# 맞춤선 글 자리에 시종점 글 후보가 처음 놓이는 견본
|
||||
"endpoints": {"items": [{"label": "시점 No.0", "x": 0.0, "y": at, "nx": 1.0, "ny": 0.0}]},
|
||||
}
|
||||
drawing = build_plan_kind_drawing("plan_x_2", "평면도(x) 2/3장", box, pieces, 1200)
|
||||
sheet = [e for e in drawing["entities"] if e["layerId"] == SHEET_LAYER]
|
||||
labels = sorted(e["shapeData"]["label"] for e in sheet if e["type"] == "Text")
|
||||
assert labels == [line["label"] for line in links["lines"]]
|
||||
assert any(layer["id"] == SHEET_LAYER for layer in drawing["layers"])
|
||||
assert sum(e["type"] == "PolyLine" for e in sheet) == 4 # 맞춤선 둘 + 이웃 노선 둘
|
||||
|
||||
|
||||
def test_목록_도면명_n_N장(tmp_path: Path):
|
||||
stations = [{"chainage_m": c, "center_x": x, "center_y": y} for c, x, y in STATIONS]
|
||||
items = [
|
||||
i for i in plan_kind_items(tmp_path, {"stations": stations}) if i["plan_kind"] == "terrain"
|
||||
]
|
||||
assert [item["label"] for item in items] == [f"평면도(지형) {n}/3장" for n in (1, 2, 3)]
|
||||
@@ -106,7 +106,7 @@ def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
|
||||
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
|
||||
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
|
||||
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
|
||||
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
|
||||
assert [(g["tab"], g["key"]) for g in sheets["gaps"] if g["tab"] == "compare"] == [
|
||||
("compare", "b08:sand"),
|
||||
("compare", "b08:cement"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표.
|
||||
|
||||
- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음
|
||||
- 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀
|
||||
단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음
|
||||
- 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장
|
||||
- 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
|
||||
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡
|
||||
NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋
|
||||
NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"}
|
||||
SP_REF = {"층": "system", "주인": None, "키": "SP-000001"}
|
||||
|
||||
|
||||
def _snapshot(folder: Path, rows, values=None, copies=None) -> Path:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
labor = {
|
||||
"줄": {"종류": "일위대가", "담은로직": rows},
|
||||
**({"참조사본": copies} if copies else {}),
|
||||
}
|
||||
files = {
|
||||
"단가.json": item_prices.to_json(item_prices.collect([STONE, NET])),
|
||||
"일위대가.json": labor,
|
||||
"입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()},
|
||||
}
|
||||
for name, data in files.items():
|
||||
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def _sheets(*folders: Path) -> dict:
|
||||
keys = [k for f in folders for k in used_keys(f)]
|
||||
return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {})
|
||||
|
||||
|
||||
def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}, {"로직": NET}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
got = sg.collect([("s1", a)], sheets)
|
||||
(block,) = got["sg"]
|
||||
assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡")
|
||||
labor = {r["key"]: r["price"] for r in sheets["labor"]}
|
||||
lines = block["lines"]
|
||||
assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [
|
||||
("labor", "LB000033", 0.195),
|
||||
("labor", "LB000002", 0.156),
|
||||
]
|
||||
want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines]
|
||||
assert [ln["labor"] for ln in lines] == want
|
||||
assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"]
|
||||
assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None
|
||||
assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == []
|
||||
assert sg.price_of(got, block["key"]) == {
|
||||
"no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
(block,) = sg.collect([("s1", a)], sheets)["sg"]
|
||||
kinds = [ln["kind"] for ln in block["lines"]]
|
||||
assert kinds == ["labor", "labor", "machine", "material", "material", "material"]
|
||||
crane = block["lines"][2]
|
||||
row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"])
|
||||
for part in sg.PARTS:
|
||||
assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1)
|
||||
net = block["lines"][3]
|
||||
applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"])
|
||||
assert net["material"] == sg.rounddown(applied * net["qty"], 1)
|
||||
assert block["sum"] == block["labor"] + block["material"] + block["expense"]
|
||||
|
||||
|
||||
def test_엑셀_6호표_뒷채움_검산():
|
||||
"""단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 ×
|
||||
중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림)."""
|
||||
zero = dict.fromkeys(sg.PARTS, 0.0)
|
||||
labor = {**zero, "labor": sg._money(165545, 0.018)}
|
||||
machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))}
|
||||
assert labor["labor"] == 2979.8
|
||||
assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8)
|
||||
assert sg.block_totals([labor, machine]) == {
|
||||
"labor": 6878, "material": 538, "expense": 912, "sum": 8328,
|
||||
} # fmt: skip
|
||||
assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸
|
||||
|
||||
|
||||
def test_같은_로직_같은_입력은_하나_다르면_둘_옛_SP_도_같은_산근(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
b = _snapshot(tmp_path / "b", [{"로직": NET, "승격": True}], {NET: {**NET_IN, "": ""}})
|
||||
other = {**NET_IN, "방지망": "비포켓식"}
|
||||
c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other})
|
||||
sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1}
|
||||
d = _snapshot(
|
||||
tmp_path / "d",
|
||||
[{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}],
|
||||
copies={"참조|system||SP-000001": sp},
|
||||
)
|
||||
got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d))
|
||||
blocks = got["sg"]
|
||||
assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [
|
||||
(1, NET, ["s1", "s2", "s4"]), # 빈 입력 칸은 무시 · 옛 SP(같은 로직 · 입력) = 같은 산근
|
||||
(2, NET, ["s3"]),
|
||||
(3, STONE, ["s4"]),
|
||||
]
|
||||
assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {})
|
||||
assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근
|
||||
|
||||
|
||||
def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch):
|
||||
got = sg.collect([("s1", tmp_path / "없음")], {})
|
||||
assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == []
|
||||
assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY]
|
||||
plain = _snapshot(tmp_path / "p", [{"로직": STONE}]) # 승격 없는 사본도 빈 표
|
||||
assert sg.collect([("s1", plain)], {})["sg"] == []
|
||||
|
||||
async def fake_materials(_project_id):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"]
|
||||
assert sheets["sg"] == [] and sheets["sg_list"] == []
|
||||
assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"]
|
||||
|
||||
|
||||
def test_단가_없으면_빈칸과_연결_없음(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}])
|
||||
got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []})
|
||||
(block,) = got["sg"]
|
||||
assert block["labor"] is None and block["sum"] is None
|
||||
assert "노무비목록표에 없음" in block["why"]
|
||||
assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])]
|
||||
|
||||
|
||||
def test_작업량_Q는_소수_둘():
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
for code, row in sorted((whole.index.get("GF") or {}).items()):
|
||||
if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])):
|
||||
continue
|
||||
try:
|
||||
env = sg._env(whole, row, {})
|
||||
except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀
|
||||
continue
|
||||
q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"]))
|
||||
if hasattr(q, "as_tuple"):
|
||||
assert q == round(q, 2), code
|
||||
return
|
||||
pytest.skip("자동값으로 도는 작업량 Q 로직 없음")
|
||||
|
||||
|
||||
def test_화면_탭_둘_서버_산근_그대로():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell
|
||||
assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell
|
||||
assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음
|
||||
tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
|
||||
assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs
|
||||
assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""145-3 이음 — B08 구조물 집계표 열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}.
|
||||
|
||||
주인 id = `linked_snapshots` 키(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 열 owners 합 = 열 계 ·
|
||||
칸을 낸 잇기 사본이 먼저(없으면 시설 본체).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from test_120_b08_drain_summary import out # noqa: F401 — 픽스처(관 밖 횡단배수 견본)
|
||||
from test_143_1_b08_linked_snapshots import _run as _run_143
|
||||
from test_143_1_b08_linked_snapshots import root # noqa: F401 — 픽스처(실제 사본 폴더)
|
||||
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
|
||||
|
||||
def _check_sums(result: dict, keys: set[str]) -> None:
|
||||
for column in result["columns"]:
|
||||
owners = column["owners"]
|
||||
assert set(owners) <= keys, column["id"]
|
||||
assert sum(owners.values()) == pytest.approx(result["total"].get(column["id"], 0.0))
|
||||
|
||||
|
||||
def test_owners_합_열_계_주인은_사본_키(out): # noqa: F811
|
||||
cols = {c["id"]: c for c in out["columns"]}
|
||||
_check_sums(out, set(out["linked"]))
|
||||
# 본체 칸 = 본체 잇기 사본(gboxbody) · 날개벽 = 유입 잇기 · 흙막이 = 유출 잇기
|
||||
assert cols["pipe:box|body|2.0×2.0"]["owners"] == {"pipe@300.0__gboxbody": 9.6}
|
||||
assert cols["pipe:box_culvert|wing|inlet"]["owners"] == {"pipe@300.0__gboxin": 1.0}
|
||||
assert cols["pipe:revet|돌쌓기(메)|2.0"]["owners"] == {"pipe@300.0__gboxout": 10.0}
|
||||
# 세월교 월류 폭 = 저장된 잇기(gford) · 관 공은 본체(사본 키 = 관 지점 id)
|
||||
assert cols["pipe:ford|1000|2|width"]["owners"] == {"pipe@400.0__gford": 12.0}
|
||||
assert cols["pipe:len|800"]["owners"] == {"pipe@300.0": 18.0}
|
||||
# B05 구조물 = 구조물 id · 그룹 잇기 = {id}__{그룹}
|
||||
assert cols["pipe:open_ditch"]["owners"] == {"s-ditch": 1.0}
|
||||
assert cols["pipe:cross_underdrain"]["owners"] == {"s-ud__gcrossud": 1.0}
|
||||
|
||||
|
||||
def test_실제_사본_폴더_키와_같다(root): # noqa: F811
|
||||
result = _run_143(root)
|
||||
linked = set(snapshots.linked_snapshots(root))
|
||||
_check_sums(result, linked)
|
||||
cols = {c["id"]: c for c in result["columns"]}
|
||||
assert cols["pipe:len|800"]["owners"] == {"pipe@100.0": 16.0}
|
||||
assert cols["pipe:len|1000"]["owners"] == {"pipe@500.0__gford": 18.0}
|
||||
@@ -0,0 +1,254 @@
|
||||
"""145-3 B09 설계내역서 — 엑셀 검산 대조 · 항목 짜기 · 원가계산서 이음 꼴 ·
|
||||
거울(`Calc_Bill.py` ↔ `.ts`).
|
||||
|
||||
견본 = 기번3 공통 엑셀 설계내역서:
|
||||
9 · 10줄 규준틀(대가 5 · 8) · 14줄 절토(토사)(산근 13) — 같은 호표 단가면 같은 금액
|
||||
246~253줄 관급자재대 — 순자재대 69,474,220 · 조달수수료 0.54 % = 375,160 · 계 69,849,380
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
#: 관급 자재(엑셀 246~252 · 재료비목록표 단가) — [키, 품명, 규격, 단위, 수량, 단가]
|
||||
OWNER = [
|
||||
("b08:remicon_25_18_80", "레미콘", "25-18-80", "M3", 32, 102970),
|
||||
("b08:pipe_800", "파형강관", "Φ800m/m, 2.7t", "M", 212, 157000),
|
||||
("b08:pipe_1000", "파형강관", "Φ1000m/m, 2.7t", "M", 132, 196270),
|
||||
("b08:pipe_1200", "파형강관", "Φ1200m/m, 3.2t", "M", 22, 273370),
|
||||
("b08:cp_800", "파형강관이음관", "Φ800m/m", "개", 22, 21200),
|
||||
("b08:cp_1000", "파형강관이음관", "Φ1000m/m", "개", 12, 32200),
|
||||
("b08:cp_1200", "파형강관이음관", "Φ1200m/m", "개", 3, 40200),
|
||||
]
|
||||
MATERIALS = [
|
||||
{"key": k, "name": n, "spec": s, "unit": u, "origin": "B08", "prices": {"관급": p}, "qty": q}
|
||||
for k, n, s, u, q, p in OWNER
|
||||
] + [
|
||||
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
|
||||
"prices": {"물가정보": 30000}, "qty": 16},
|
||||
{"key": "b08:rubble", "name": "골재", "spec": "막자갈", "unit": "㎥", "origin": "B08",
|
||||
"prices": {}, "qty": 5},
|
||||
] # fmt: skip
|
||||
ITEMS = [
|
||||
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
|
||||
"ref": {"tab": "dg_list", "key": "B00194"}},
|
||||
{"key": "earth:2", "section": "1.1", "name": "규준틀설치 횡단규준틀", "unit": "개소", "qty": 28,
|
||||
"ref": {"tab": "dg_list", "key": "B00467"}},
|
||||
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
|
||||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||||
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
|
||||
"ref": None,
|
||||
"why": "조합 밖 토공 산근 — 연결 없음"},
|
||||
] # fmt: skip
|
||||
HOPYO = {
|
||||
"B00194": {"labor": 21936, "material": 3885, "expense": 0}, # 일위대가목록표 F8~H8
|
||||
"B00467": {"labor": 33032, "material": 6942, "expense": 0},
|
||||
"D01341": {"labor": 1116, "material": 360, "expense": 463}, # 단가산출근거목록표 16줄
|
||||
}
|
||||
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
||||
USER = {
|
||||
"materials": {**{k: {"supply": "관급"} for k, *_ in OWNER}, "b08:sand": {"supply": "별도"},
|
||||
"b08:rubble": {"supply": "별도"}},
|
||||
"estimate": {"procurement_fee_pct": 0.54},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run_ts(inputs, user):
|
||||
files = ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
|
||||
source = "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) for f in files
|
||||
)
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
|
||||
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(computeEstimation(i, u)));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", source + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _rows(sheets):
|
||||
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
||||
|
||||
|
||||
def test_호표_단가_줄_엑셀_금액():
|
||||
rows = _rows(compute_estimation(INPUTS, USER))
|
||||
row = rows["earth:1"]
|
||||
assert (row["price_sum"], row["labor"], row["material"], row["expense"], row["sum"]) == (
|
||||
25821, 307104, 54390, 0, 361494,
|
||||
) # fmt: skip # 설계내역서 F9 · I9 · K9 · M9 · G9
|
||||
assert rows["earth:2"]["sum"] == 1119272 # G10
|
||||
row = rows["earth:4"]
|
||||
assert (row["labor"], row["material"], row["expense"], row["sum"]) == (
|
||||
6333300, 2043000, 2627525, 11003825,
|
||||
) # fmt: skip # 설계내역서 I14 · K14 · M14 · G14
|
||||
assert rows["earth:5"]["sum"] is None and rows["earth:5"]["qty"] == 972 # 단가 연결 없음
|
||||
|
||||
|
||||
def test_관급자재대_조달수수료_엑셀():
|
||||
sheets = compute_estimation(INPUTS, USER)
|
||||
rows = _rows(sheets)
|
||||
assert rows["mat:b08:pipe_800"]["material"] == 33284000 # K247
|
||||
assert rows["mat:b08:remicon_25_18_80"]["section"] == "G"
|
||||
fee = rows["fee"]
|
||||
assert (fee["qty"], fee["price_material"], fee["material"]) == (0.54, 69474220, 375160) # 253줄
|
||||
assert rows["sec:G"]["material"] == 69849380 # K245 관급자재대
|
||||
assert "(제외금액)" in rows["sec:G"]["note"]
|
||||
owner = sheets["bill"]["totals"]["owner"]
|
||||
assert owner == {"net_material": 69474220, "fee": 375160, "total": 69849380}
|
||||
# 「별도」 = 4 자재대 · 단가 없는 별도 자재 = 빈 줄
|
||||
assert rows["mat:b08:sand"]["section"] == "4" and rows["mat:b08:sand"]["material"] == 480000
|
||||
assert rows["mat:b08:rubble"]["sum"] is None and rows["sec:4"]["blank"] == 1
|
||||
|
||||
|
||||
def test_제목_합_빈_줄과_원가계산서_이음():
|
||||
sheets = compute_estimation(INPUTS, USER)
|
||||
rows = _rows(sheets)
|
||||
keys = [row["key"] for row in sheets["bill"]["rows"] if row["kind"] == "title"]
|
||||
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
|
||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
||||
assert sheets["bill"]["sums"] == {
|
||||
"material": None,
|
||||
"labor": None,
|
||||
"expense": None,
|
||||
"supply_net": 69474220,
|
||||
}
|
||||
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
||||
sums = compute_estimation(full, USER)["bill"]["sums"]
|
||||
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
||||
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
||||
assert sums["expense"] == 2627525
|
||||
|
||||
|
||||
def test_조달수수료_율_빈칸이면_빈칸():
|
||||
user = {**USER, "estimate": {}}
|
||||
sheets = compute_estimation(INPUTS, user)
|
||||
fee = _rows(sheets)["fee"]
|
||||
assert fee["material"] is None and fee["qty"] is None
|
||||
assert ("bill", "fee") in {(g["tab"], g["key"]) for g in sheets["gaps"]}
|
||||
assert sheets["bill"]["totals"]["owner"]["total"] == 69474220
|
||||
|
||||
|
||||
def test_항목_짜기_B08_수량():
|
||||
earthwork = {"summary": [
|
||||
{"group": "흙깍기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", "value": 5675},
|
||||
{"group": "흙깍기", "kind": "연암", "spec": "굴삭기 + 브레카", "unit": "㎥", "value": 972},
|
||||
{"group": "토사운반", "kind": "", "spec": "BH+DT+BH", "unit": "㎥", "value": 120},
|
||||
{"group": "토사운반", "kind": "", "spec": "19 TON B/D", "unit": "㎥", "value": 80},
|
||||
{"group": "구조물터파기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥",
|
||||
"value": None},
|
||||
]} # fmt: skip
|
||||
summary = {
|
||||
"columns": [
|
||||
{"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"],
|
||||
"label": "m", "combos": ["기슭막이 찰"]},
|
||||
{"id": "pipe:len|800", "group": ["관 공", "Φ800m/m", "관매설"], "label": "m"},
|
||||
{"id": "pipe:cp|800", "group": ["관 공", "Φ800m/m", "커플링"], "label": "ea"},
|
||||
{"id": "pipe:chute|찰붙임", "group": ["돌붙임", "찰붙임"], "label": "㎡"},
|
||||
],
|
||||
"total": {"pipe:revet|돌쌓기(찰)|2.0": 20, "pipe:len|800": 84, "pipe:cp|800": 9},
|
||||
} # fmt: skip
|
||||
materials = {"material": {"rows": [
|
||||
{"key": "sand", "amount": 16}, {"key": "rebar_d13", "amount": 0.5},
|
||||
{"key": "rebar_d16", "amount": 0.25}, {"key": "gravel", "amount": None},
|
||||
]}} # fmt: skip
|
||||
items = {i["key"]: i for i in bill_items({"earthwork": earthwork, "summary": summary,
|
||||
"materials": materials})["items"]} # fmt: skip
|
||||
assert items["earth:4"]["qty"] == 5675 and items["earth:4"]["name"] == "절토(토사)"
|
||||
assert items["earth:5"]["qty"] == 972 and items["earth:6"]["qty"] is None # 경암 줄 없음
|
||||
assert items["earth:13"]["qty"] == 80 and items["earth:14"]["qty"] == 120 # 도자 · 덤프
|
||||
assert items["earth:7"]["qty"] is None # 구조물터파기 = B08 도 빈칸
|
||||
assert items["earth:1"]["why"].startswith("준비공") and items["earth:4"]["ref"] is None
|
||||
st = {k: v for k, v in items.items() if k.startswith("st:")}
|
||||
assert [(v["section"], v["qty"]) for v in st.values()] == [
|
||||
("2.1", 20),
|
||||
("2.2", 84),
|
||||
("2.3", None),
|
||||
]
|
||||
assert "st:pipe:cp|800" not in items # 커플링 = 관급 자재
|
||||
assert items["st:pipe:revet|돌쌓기(찰)|2.0"]["ref"] == {
|
||||
"tab": "dg_list",
|
||||
"key": "pipe:revet|돌쌓기(찰)|2.0",
|
||||
}
|
||||
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
|
||||
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
|
||||
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
for user in (USER, {**USER, "estimate": {}}, {}):
|
||||
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
|
||||
|
||||
|
||||
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본) · 엑셀 일위대가목록표 대가17 · 24
|
||||
DAEGA = {
|
||||
"pipe@100.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
|
||||
"pipe@200.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
|
||||
"pipe@300.0__gsoiladd1": {"no": "대가24", "labor": 129370, "material": 15059, "expense": 21833},
|
||||
}
|
||||
REVET = {
|
||||
"key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이",
|
||||
"spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "",
|
||||
"ref": {"tab": "dg_list", "key": "pipe:revet|돌쌓기(찰)|2.0"},
|
||||
"owners": {"pipe@100.0__gsoiladd1": 10, "pipe@300.0__gsoiladd1": 12.5,
|
||||
"pipe@200.0__gsoiladd1": 10, "pipe@400.0__gsoiladd1": 10},
|
||||
} # fmt: skip
|
||||
SPLIT_INPUTS = {**INPUTS, "bill": {"items": [REVET]}}
|
||||
|
||||
|
||||
def test_구조물_열_대가_블록마다_나눔():
|
||||
sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
|
||||
rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")]
|
||||
got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows]
|
||||
assert got == [
|
||||
("대가17", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
|
||||
("대가24", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
|
||||
("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」
|
||||
]
|
||||
assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림
|
||||
assert ("bill", "st:pipe:revet|돌쌓기(찰)|2.0") in {
|
||||
(g["tab"], g["key"]) for g in sheets["gaps"]
|
||||
}
|
||||
# 대가 잇기 전(price_of 없음) = 열 하나 그대로 · 단가 빈칸
|
||||
plain = compute_estimation(SPLIT_INPUTS, USER)
|
||||
one = [r for r in plain["bill"]["rows"] if r["key"].startswith("st:")]
|
||||
assert [(r["qty"], r["sum"]) for r in one] == [(42.5, None)]
|
||||
|
||||
|
||||
def test_거울_블록_나누기():
|
||||
calc = "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8"))
|
||||
for f in ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
|
||||
)
|
||||
body = (
|
||||
f"\nconst d = {json.dumps(DAEGA, ensure_ascii=False)};"
|
||||
f"\nconst i = {json.dumps(SPLIT_INPUTS, ensure_ascii=False)};"
|
||||
f"\nconst u = {json.dumps(USER, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(computeEstimation(i, u, (o) => d[o] ?? null)));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", calc + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
|
||||
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""145-4 B09 총괄설계내역서 · 공사원가계산서 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`).
|
||||
|
||||
견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 총괄 48 비목 합 · M49~M64 요율 · N55 기초액 ·
|
||||
I62 이윤 보정 · 설계내역서 J253 관급 순자재대 · D253 조달수수료율 → 총괄 D49~D67 · E55 A/B · 원가계산서 구성비.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
|
||||
RATES = {
|
||||
"indirect_labor": 14.5,
|
||||
"accident": 3.56,
|
||||
"employment": 1.01,
|
||||
"health": 3.545,
|
||||
"care": 12.95,
|
||||
"pension": 4.5,
|
||||
"safety": 1.86,
|
||||
"other": 6,
|
||||
"environment": 0.8,
|
||||
"machine": 0.4,
|
||||
"overhead": 6,
|
||||
"profit": 15,
|
||||
"vat": 10,
|
||||
}
|
||||
ESTIMATE = {
|
||||
"rates": RATES,
|
||||
"safety_base": 5349000,
|
||||
"profit_adjust": 905980,
|
||||
"procurement_fee_pct": 0.54,
|
||||
}
|
||||
|
||||
EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66
|
||||
"indirect_labor": 50286080,
|
||||
"accident": 14136284,
|
||||
"employment": 4010575,
|
||||
"health": 12294079,
|
||||
"care": 1592083,
|
||||
"pension": 15606024,
|
||||
"safety": 16586996,
|
||||
"other": 35479263,
|
||||
"environment": 5682873,
|
||||
"machine": 2841436,
|
||||
"overhead": 52132489,
|
||||
"profit": 108109955,
|
||||
"vat": 102911727,
|
||||
"supply": 69850000,
|
||||
}
|
||||
EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67
|
||||
"net_cost": 868874829,
|
||||
"da": 921007318,
|
||||
"supply_price": 1029117273,
|
||||
"contract": 1132029000,
|
||||
"grand": 1201879000,
|
||||
}
|
||||
|
||||
|
||||
def test_엑셀_검산_총괄():
|
||||
out = compute_total(SUMS, ESTIMATE)
|
||||
assert out["sums"]["direct"] == 710359136 # 가. 순공사비계 D48
|
||||
assert out["lines"] == EXCEL_LINES
|
||||
assert out["subtotals"] == EXCEL_SUBTOTALS
|
||||
assert out["safety"] == {"a": 16586996, "b": 18494700} # E55 글 A · B
|
||||
assert out["profit_raw"] == 109015935 # J62 보정 전
|
||||
assert out["supply"] == {"net": 69474220, "fee": 375160, "raw": 69849380} # I66 · O66
|
||||
|
||||
|
||||
def test_엑셀_검산_원가계산서_거울_구성비():
|
||||
cost = compute_total(SUMS, ESTIMATE)["cost"]
|
||||
share = compute_total(SUMS, ESTIMATE)["share"]
|
||||
assert cost["direct_material"] == 194234417 and cost["material"] == 194234417 # D5 · D8
|
||||
assert cost["labor"] == 397086634 # D11
|
||||
assert cost["expense_sum"] == 277553778 # D22
|
||||
assert cost["supply_price"] == 1029117273 and cost["grand"] == 1201879000 # D25 · D29
|
||||
assert share["direct_material"] == pytest.approx(16.160896146783497) # E5
|
||||
assert share["labor"] == pytest.approx(33.03881954839048) # E11
|
||||
assert share["supply"] == pytest.approx(5.8117331278772655) # E28
|
||||
assert share["grand"] == 100
|
||||
|
||||
|
||||
def test_간접재료비_작업설은_직접재료비만_바꿈():
|
||||
est = {**ESTIMATE, "indirect_material": 1000, "scrap": 300}
|
||||
out = compute_total(SUMS, est)
|
||||
assert out["cost"]["direct_material"] == 194234417 - 1000 + 300 # 엑셀 D5 = F48 − D6 + D7
|
||||
assert out["cost"]["material"] == 194234417
|
||||
assert out["subtotals"] == EXCEL_SUBTOTALS
|
||||
|
||||
|
||||
def test_빈칸_규칙():
|
||||
blank = compute_total(SUMS, {})
|
||||
assert all(v is None for k, v in blank["lines"].items() if k != "supply")
|
||||
assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None
|
||||
assert blank["lines"]["supply"] is None # 조달수수료율 빈칸
|
||||
# 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈
|
||||
one = compute_total(SUMS, {**ESTIMATE, "rates": {**RATES, "care": None}})
|
||||
assert one["lines"]["care"] is None and one["lines"]["health"] == 12294079
|
||||
assert one["subtotals"]["net_cost"] is None and one["lines"]["supply"] == 69850000
|
||||
# 비목 합이 아직 없음(145-3 전) — 모두 빈칸
|
||||
none = compute_total(None, ESTIMATE)
|
||||
assert none["subtotals"]["grand"] is None and none["lines"]["indirect_labor"] is None
|
||||
# 관급 없음 — 수수료율 빈칸이어도 관급 0
|
||||
zero = compute_total({**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None})
|
||||
assert zero["lines"]["supply"] == 0 and zero["subtotals"]["grand"] is not None
|
||||
|
||||
|
||||
def test_안전관리비_B_가_작으면_B():
|
||||
big = compute_total({**SUMS, "supply_net": 2_000_000_000}, ESTIMATE)
|
||||
assert big["safety"]["a"] > big["safety"]["b"]
|
||||
assert big["lines"]["safety"] == big["safety"]["b"]
|
||||
|
||||
|
||||
def test_요율_후보_글():
|
||||
cand = rate_candidates()
|
||||
assert cand["RT000001"] == "M01 RT000001 3.56%"
|
||||
assert "구간" in cand["RT000002"] and cand["RT000019"].endswith("10%")
|
||||
|
||||
|
||||
def _run_ts(cases: list) -> list:
|
||||
base = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts"
|
||||
calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Total.ts"
|
||||
body = (
|
||||
f"\nconst cases = {json.dumps(cases, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(cases.map(([s, e]) => computeTotal(s, e))));"
|
||||
)
|
||||
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
cases = [
|
||||
[SUMS, ESTIMATE],
|
||||
[SUMS, {**ESTIMATE, "indirect_material": 1000, "scrap": 300, "vat_adjust": -27}],
|
||||
[SUMS, {}],
|
||||
[None, ESTIMATE],
|
||||
[{**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}],
|
||||
[{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE],
|
||||
[{**SUMS, "labor": None}, ESTIMATE],
|
||||
[SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}],
|
||||
]
|
||||
assert _run_ts(cases) == [compute_total(s, e) for s, e in cases]
|
||||
|
||||
|
||||
def test_화면_탭_요율_손입력_칸():
|
||||
"""총괄 탭 — 요율 · 손입력 칸만 고침 · 빈칸 = 지움 · 원가계산서 줄 누름 = 총괄 그 줄."""
|
||||
files = [
|
||||
ROOT / "B09_Estimation" / name
|
||||
for name in (
|
||||
"B09_Estimation_Calc_Base.ts",
|
||||
"B09_Estimation_Calc_Total.ts",
|
||||
"B09_Estimation_UI_Tabs.ts",
|
||||
"B09_Estimation_UI_Tabs_Total.ts",
|
||||
)
|
||||
]
|
||||
body = f"""
|
||||
let user = {{ estimate: {json.dumps(ESTIMATE)} }};
|
||||
const setUser = (u) => (user = u);
|
||||
const sums = {json.dumps(SUMS)};
|
||||
const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate) }}, payload: {{}}, user, setUser }});
|
||||
let t = buildTotal(ctx());
|
||||
const at = (rows, key) => rows.findIndex((r) => r.key === key);
|
||||
const out = {{}};
|
||||
out.editable = [
|
||||
t.editable(at(t.rows, "care"), "rate"), t.editable(at(t.rows, "care"), "hand"),
|
||||
t.editable(at(t.rows, "safety"), "hand"), t.editable(at(t.rows, "retirement"), "rate"),
|
||||
t.editable(at(t.rows, "net_cost"), "rate"),
|
||||
];
|
||||
t.onEdit(at(t.rows, "care"), "rate", "13.14");
|
||||
out.care = user.estimate.rates.care;
|
||||
t = buildTotal(ctx());
|
||||
t.onEdit(at(t.rows, "care"), "rate", "");
|
||||
out.careGone = "care" in user.estimate.rates;
|
||||
t = buildTotal(ctx());
|
||||
out.grandBlank = t.rows[at(t.rows, "grand")].amount;
|
||||
t.onEdit(at(t.rows, "safety"), "hand", "1,000");
|
||||
out.base = user.estimate.safety_base;
|
||||
const c = buildCost(ctx());
|
||||
out.link = c.link(at(c.rows, "safety"));
|
||||
c.onEdit(at(c.rows, "scrap"), "amount", "300");
|
||||
out.scrap = user.estimate.scrap;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = json.loads(result.stdout)
|
||||
assert out["editable"] == [True, False, True, False, False]
|
||||
assert out["care"] == 13.14 and out["careGone"] is False
|
||||
assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸
|
||||
assert out["base"] == 1000 and out["scrap"] == 300
|
||||
assert out["link"] == {"tab": "total", "key": "safety"}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""145-5 B09 집계표 넷 — 역전개(겹 호표) · 엑셀 끝수 · 검산 대조(서버 단독 · 거울 없음).
|
||||
|
||||
견본 = 기번3 공통 엑셀 집계표:
|
||||
중기시간금액집계표 4줄 불도우저19ton 42.95 시간 → 2,392,315 · 1,584,340 · 1,435,045 = 5,411,700
|
||||
노무비수량금액집계표 4줄 건축목공 8.29 인 × 268,058 = 2,222,201
|
||||
재료비수량금액집계표 각재 0.56 × 500,661 = 280,370 · 못 3.54 × 1,503 = 5,321
|
||||
경비수량금액집계표 구역화물자동차 11.03 회 × 61,500 = 678,345
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary # noqa: E402
|
||||
|
||||
SHEETS = {
|
||||
"labor": [{"key": "L1", "name": "건축목공", "spec": "", "unit": "인", "price": 268058}],
|
||||
"material": [
|
||||
{"key": "M3", "name": "각재", "spec": "외송", "unit": "M3", "price": 500661},
|
||||
{"key": "M4", "name": "못", "spec": "N 50", "unit": "KG", "price": 1503},
|
||||
],
|
||||
"expense": [
|
||||
{"key": "E16", "name": "구역화물자동차", "spec": "5톤", "unit": "회", "price": 61500}
|
||||
],
|
||||
"machine": [
|
||||
{
|
||||
"key": "X1",
|
||||
"name": "불도우저19ton",
|
||||
"spec": "(토사)",
|
||||
"unit": "시간",
|
||||
"sum": 126000,
|
||||
"labor": 55700,
|
||||
"material": 36888,
|
||||
"expense": 33412,
|
||||
},
|
||||
], # fmt: skip
|
||||
"bill": {
|
||||
"rows": [
|
||||
{"kind": "title", "key": "sec:1", "qty": None},
|
||||
{"kind": "item", "key": "a", "qty": 10, "ref": {"tab": "sg_list", "key": "A"}},
|
||||
{"kind": "item", "key": "m", "qty": 0.56, "ref": {"tab": "material", "key": "M3"}},
|
||||
{"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "A"}},
|
||||
{"kind": "item", "key": "loop", "qty": 1, "ref": {"tab": "dg_list", "key": "C"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
LINES = {
|
||||
"A": {"lines": [
|
||||
{"kind": "machine", "key": "X1", "qty": 4.295},
|
||||
{"kind": "hopyo", "key": "B", "qty": 0.5},
|
||||
{"kind": "material", "key": "M4", "qty": 0.354},
|
||||
{"kind": "expense", "key": "E16", "qty": 1.103},
|
||||
]},
|
||||
"B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658},
|
||||
{"kind": "labor", "key": "L9", "qty": 1}]},
|
||||
"C": {"lines": [{"kind": "hopyo", "key": "C", "qty": 1}]}, # 스스로 부름 — 깊이 한도에서 멈춤
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_역전개_엑셀_금액():
|
||||
s = compute_summary(SHEETS, LINES)
|
||||
machine = s["sum_machine"][0]
|
||||
assert (machine["qty"], machine["labor"], machine["material"], machine["expense"]) == (
|
||||
42.95, 2392315, 1584340, 1435045,
|
||||
) # fmt: skip
|
||||
assert machine["sum"] == 5411700
|
||||
labor = {r["key"]: r for r in s["sum_labor"]}
|
||||
assert (labor["L1"]["qty"], labor["L1"]["amount"]) == (
|
||||
8.29,
|
||||
2222201,
|
||||
) # 겹 호표 10 × 0.5 × 1.658
|
||||
assert labor["L9"]["amount"] is None and labor["L9"]["note"] == "목록표에 없음"
|
||||
material = {r["key"]: (r["qty"], r["amount"]) for r in s["sum_material"]}
|
||||
assert material == {"M4": (3.54, 5321), "M3": (0.56, 280370)} # 내역 자재 줄 그대로
|
||||
assert [(r["qty"], r["amount"]) for r in s["sum_expense"]] == [(11.03, 678345)]
|
||||
|
||||
|
||||
def test_호표_구성_없으면_자재_줄만():
|
||||
s = compute_summary(SHEETS, {})
|
||||
assert s["sum_labor"] == [] and s["sum_machine"] == [] and s["sum_expense"] == []
|
||||
assert [r["key"] for r in s["sum_material"]] == ["M3"]
|
||||
|
||||
|
||||
def test_GET_응답에_집계표(tmp_path, monkeypatch):
|
||||
async def fake_materials(_project_id):
|
||||
return {"material": {"rows": [{"key": "sand", "group": "골재", "spec": "모래",
|
||||
"unit": "㎥", "amount": 16}]}} # fmt: skip
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
body = asyncio.run(router_mod.payload("p", tmp_path))
|
||||
assert set(body["summary"]) == {"sum_material", "sum_labor", "sum_expense", "sum_machine"}
|
||||
assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음
|
||||
@@ -72,8 +72,7 @@ _TABLE = {"ground": 100.0, "planned": 100.0}
|
||||
def test_each_block_hugs_its_own_drawing_by_one_mm() -> None:
|
||||
"""크기 다른 단면 셋을 한 장에 — 박스마다 자기 그림(표 포함)에서 사방 1.0 mm."""
|
||||
sources = [_source(0.0, 0.05), _source(20.0, 0.6, 15), _source(40.0, 0.2, 6)]
|
||||
blocks = [(s["chainage_m"], *section_block_size(s, None, design=None)) for s in sources]
|
||||
sheets = plan_cross_sheets(blocks)
|
||||
sheets = plan_cross_sheets([s["chainage_m"] for s in sources])
|
||||
assert len(sheets) == 1
|
||||
sections = [
|
||||
{"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""}
|
||||
@@ -100,13 +99,10 @@ def test_each_block_hugs_its_own_drawing_by_one_mm() -> None:
|
||||
assert top - max(b[3] for b in mine) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_small_blocks_are_not_widened_by_a_big_one() -> None:
|
||||
"""큰 단면 하나 + 작은 단면 여럿 — 칸을 큰 블록으로 통일하던 때보다 한 장에 더 담긴다."""
|
||||
blocks = [(0.0, 300.0, 300.0)] + [(float(i), 110.0, 60.0) for i in range(1, 30)]
|
||||
sheets = plan_cross_sheets(blocks)
|
||||
# 옛 배치(칸 300×300 → A1 739×499 에 2×1)는 첫 장에 2개였다.
|
||||
assert len(sheets[0]["chainages"]) > 2
|
||||
assert sum(len(s["chainages"]) for s in sheets) == 30
|
||||
def test_sheets_take_grid_count_not_block_size() -> None:
|
||||
"""140-3 장 나눔 = 칸 수(기본 4 × 4) — 단면 크기와 무관 · 측점 30개면 16 + 14."""
|
||||
sheets = plan_cross_sheets([float(i) for i in range(30)])
|
||||
assert [len(s["chainages"]) for s in sheets] == [16, 14]
|
||||
|
||||
|
||||
def test_window_stops_where_lines_end() -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
|
||||
⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 ·
|
||||
용지도 상자 안 선 0
|
||||
⑨ 종류 차례(140-2) — 끌어 놓은 차례 그대로 · 다시 읽어 같음 · 앞선 종류가 바뀐 종류만 확정 풂 · 목록 어긋나면 거절
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -43,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
list_kinds,
|
||||
plan_kind_items,
|
||||
read_marks,
|
||||
reorder_kinds,
|
||||
slope_records,
|
||||
update_kind,
|
||||
write_marks,
|
||||
@@ -442,3 +444,27 @@ def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None:
|
||||
assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽
|
||||
assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음)
|
||||
assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로
|
||||
|
||||
|
||||
def test_reorder_kinds_order_and_release_renumbered(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
manifest = _read_manifest(root)
|
||||
manifest["drawings"] = {
|
||||
"plan_terrain": {"confirmed": True},
|
||||
"plan_route": {"confirmed": True},
|
||||
"plan_layout": {"confirmed": True},
|
||||
}
|
||||
_write_manifest(root, manifest)
|
||||
before = [k["id"] for k in list_kinds(root)]
|
||||
assert before[:3] == ["terrain", "route", "layout"]
|
||||
after = ["route", "terrain", *before[2:]] # 앞 둘만 바꿈
|
||||
reorder_kinds(root, after)
|
||||
assert [k["id"] for k in list_kinds(root)] == after # 다시 읽어 같음
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
assert drawings["plan_terrain"]["confirmed"] is False # 번호 바뀜
|
||||
assert drawings["plan_route"]["confirmed"] is False
|
||||
assert drawings["plan_layout"]["confirmed"] is True # 앞선 종류 묶음 그대로 = 번호 그대로
|
||||
with pytest.raises(ValueError):
|
||||
reorder_kinds(root, after[:-1]) # 빠진 종류
|
||||
with pytest.raises(ValueError):
|
||||
reorder_kinds(root, [*after[:-1], "terrain"]) # 겹친 종류
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface CadLoadExtra {
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
viewOnly?: boolean;
|
||||
/** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다(B07 평면도 관리 모달). */
|
||||
bare?: boolean;
|
||||
/** 좌표 0,0 에 기준점 십자를 그린다(그리기 겹판 · 저장 안 됨) — 안 보내면 없음. */
|
||||
originMarker?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user