Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-09-30 10:49:03 +09:00
240 changed files with 8592 additions and 14398 deletions
+4 -1
View File
@@ -15,7 +15,8 @@ import { requestJson } from "./B05_Profile_Api_Replan";
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
export type RouteInitialLogic =
"follow" | "gradeline" | "earthwork" | "gentle" | "safe" | "crossings" | "cost" | "weighted";
/** 비중 계산의 세 비중(%) — 합 100. */
export interface RouteInitialWeights {
@@ -31,6 +32,8 @@ export interface RouteInitialCriteria {
min_radius_m?: number;
stream_offset_m?: number;
grade_smooth_m?: number;
/** 영선 노선(`gradeline`)의 목표 종단기울기(%) — 비우면 종단기울기 상한. */
target_grade_pct?: number;
}
export interface RouteInitialRequest {
+69 -112
View File
@@ -1,4 +1,7 @@
"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20 · 23장).
"""초기 계획노선 갈래(PLAN 20 · 23장).
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
공사비 어림 최소 · 비중.
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
@@ -8,13 +11,21 @@
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보
범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 —
토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e).
· gradeline — 영선 노선: 목표 기울기(`target_grade` · 없으면 상한)로 등고선을 건너가는
등경사선(PEGGER 식) · 후보 중 목표 기울기 편차(`grade_dev_pct`) 최소(PLAN 23-5).
· safe — 안전 우선: 후보 중 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) 최소.
· crossings — 계류 횡단 최소: 후보 중 계류 횡단 수 최소 · 같으면 계류에서 먼 쪽.
· cost — 공사비 어림 최소: 후보 중 상대 공사비(토공 + 계류 횡단 구조물 + 연장 · 임시 상대
단가 `..._Metrics.COST_*`) 최소.
· weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`).
끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 =
기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸.
후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들.
갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값
(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1).
후보 묶음(`..._Pool.build_pool`) = 목표를 바꿔 가며 격자 탐색(`..._Search`)한 노선들 + 토공 ↔ 기울기
비중 쓸기. 갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는
비교값(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1).
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
@@ -46,6 +57,13 @@ from typing import Any
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
pick,
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CORRIDOR_M,
ROAD_WIDTH_M,
@@ -70,24 +88,21 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
PENDING: tuple[str, ...] = ()
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
MODES = ("follow", "earthwork", "gentle", "weighted")
MODES = ("follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted")
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
MODE_WEIGHTS = {
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
# 비중 몫이 없는 갈래(PLAN 23-5) — 제 목표로 찾은 후보에서 비교값으로 고름
**{
mode: {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
for mode in ("gradeline", "safe", "crossings", "cost")
},
}
# 후보를 만들 탐색 비중 — 목표 하나씩
CANDIDATE_WEIGHTS = (MODE_WEIGHTS["earthwork"], MODE_WEIGHTS["gentle"], MODE_WEIGHTS["follow"])
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
RELAXED_GRADE_COSTS = (50.0, 5.0)
# 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리)
POOL_CACHE_SIZE = 4
_POOLS: dict[bytes, tuple[list[tuple[Any, dict[str, Any]]], float | None]] = {}
_POOLS: dict[bytes, Pool] = {}
def load_terrain_and_streams(
@@ -200,12 +215,13 @@ def generate_initial_route(
min_radius_m: float,
stream_offset_m: float | None = None,
grade_smooth_m: float | None = None,
target_grade: float | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
follow 밖 갈래는 후보(`_candidates`) 중 제 목표 비교값이 가장 좋은 것을 고른다.
`stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서
프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
follow 는 전처리 초기값 규칙 그대로 · weighted 는 비중 탐색 한 번(끝값은 그 갈래) · 나머지는
후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을
안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
@@ -214,8 +230,12 @@ def generate_initial_route(
used = resolve_weights(mode, weights)
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
def measure(outline: Any, bound: float | None, follow: bool = False) -> dict[str, Any]:
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
) -> dict[str, Any]:
return route_metrics(
outline.vertices,
expected,
@@ -229,22 +249,26 @@ def generate_initial_route(
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
return outline, measure(outline, None, follow=True)
return outline, measure(outline, bound=None, follow=True)
bound = None
# 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름
if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0):
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
key = _pool_key(
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth, goal_grade
)
if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길)
outline, metrics = follow_line()
pool = _POOLS.get(key)
else:
key = _pool_key(
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
)
if key not in _POOLS:
grid = SearchGrid(
expected,
@@ -252,13 +276,18 @@ def generate_initial_route(
streams,
stream_offset_m=stream_offset,
grade_smooth_m=grade_smooth,
target_grade=goal_grade,
)
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
_POOLS.pop(next(iter(_POOLS)))
_POOLS[key] = _candidates(graph, max_grade, min_radius_m, measure)
candidates, bound = _POOLS[key]
outline, metrics = _pick(mode, used, candidates)
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
outline, metrics = pick(target, pool.candidates)
return {
"mode": mode,
"logic": mode,
@@ -270,6 +299,9 @@ def generate_initial_route(
"nodes": [node.as_dict() for node in outline.nodes],
"curves": [curve.as_dict() for curve in outline.curves],
"metrics": metrics,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline),
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
"max_grade_pct": round(max_grade * 100.0, 2),
"min_radius_m": round(min_radius_m, 2),
@@ -277,63 +309,19 @@ def generate_initial_route(
"road_width_m": ROAD_WIDTH_M,
"corridor_m": CORRIDOR_M,
"grade_smooth_m": round(grade_smooth, 2),
"target_grade_pct": round(goal_grade * 100.0, 2),
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
"pending": list(PENDING),
},
}
def _candidates(
graph: RouteGraph,
max_grade: float,
min_radius_m: float,
measure: Any,
) -> tuple[list[tuple[Any, dict[str, Any]]], float | None]:
"""갈래가 고를 후보 노선들(폴리라인 · 비교값)과 지형상 불가능 때 막은 기울기(아니면 None).
제약을 지키는 길이 있으면 비중마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 비중을 바꿔 찾은 노선들. 못 지킨 곳 수가 기준
(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
"""
def trace(nodes: list[tuple[float, float]], bound: float | None) -> tuple[Any, dict]:
# 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선
outline = build_planned_polyline(
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
)
return outline, measure(outline, bound)
found: list[tuple[Any, dict[str, Any]]] = []
for weights in CANDIDATE_WEIGHTS:
for step in GRADE_CAP_STEPS:
try:
nodes = graph.solve(weights, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(trace(nodes, None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 비중과 무관하니 더 볼 것 없음
break
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None
# 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를
# 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다.
bound = graph.terrain_bound()
cap = bound + 1e-9
seed = trace(
graph.solve(DEFAULT_WEIGHTS, cap=cap, strict_stream=False, grade_cost=SOFT_GRADE_COST),
bound,
)
found = [seed]
for weights in CANDIDATE_WEIGHTS:
for cost in RELAXED_GRADE_COSTS:
nodes = graph.solve(weights, cap=cap, strict_stream=False, grade_cost=cost)
found.append(trace(nodes, bound))
most = seed[1]["over_limit"]["total"]
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound
def _weighted_end(weights: dict[str, float]) -> str:
"""비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색."""
for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")):
if all(value == 0 for key, value in weights.items() if key != name):
return mode
return "weighted"
def _pool_key(
@@ -348,34 +336,3 @@ def _pool_key(
digest.update(np.ascontiguousarray(array).tobytes())
digest.update(repr((streams, numbers)).encode())
return digest.digest()
def _pick(
mode: str, weights: dict[str, float], candidates: list[tuple[Any, dict[str, Any]]]
) -> tuple[Any, dict[str, Any]]:
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보."""
def earth(metrics: dict[str, Any]) -> float:
return metrics["cut_m3"] + metrics["fill_m3"]
def grade(metrics: dict[str, Any]) -> float:
# 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01)
return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"]
def offset(metrics: dict[str, Any]) -> float:
return metrics["offset_avg_m"]
if mode == "earthwork":
return min(candidates, key=lambda item: earth(item[1]))
if mode == "gentle":
return min(
candidates, key=lambda item: (item[1]["max_grade_pct"], item[1]["avg_grade_pct"])
)
# 비중 — 세 값을 후보 범위로 0~1 로 맞춘 뒤 비중으로 더함(단위가 다른 값을 겨루게)
parts = []
for key, value in (("expected", offset), ("earthwork", earth), ("grade", grade)):
values = [value(item[1]) for item in candidates]
low, span = min(values), max(values) - min(values)
parts.append([weights[key] * ((v - low) / span if span > 1e-9 else 0.0) for v in values])
scores = [sum(column) for column in zip(*parts)]
return candidates[scores.index(min(scores))]
@@ -25,6 +25,17 @@ SAMPLE_M = 2.0 # 측정 간격(m)
GRADE_SMOOTH_M = 5.0
STATION_M = 20.0 # 종단기울기 측정 간격(m) — 측점 체계 · 타당성평가 20 m 간격
GRADE_EPS = 1e-6 # 상한과 같은 기울기는 지킨 것으로 봄
# 안전 우선 — 옆경사가 이보다 급한 곳을 위험으로 봄(35° = 70 % · 지식DB 「35° 이상 전폭 절토」
# `산림과임업기술(임도)/2. 임도/4. 노선측량.md`)
SAFE_SIDE_SLOPE = math.tan(math.radians(35.0))
# 공사비 어림 상대 단가(절성토 1 ㎥ = 1) — ⚠ 임시 상대값 · 브레인 임의 판정(PLAN 23-5 · 지식DB
# 근거가 붙으면 바꿈). 연장 1 m = 벌개 · 노반 · 측구 등 절성토 밖 공종을 흙 2 ㎥ 로 어림(이 현장
# 절성토가 1 m 당 3~4 ㎥ 라 두 몫이 같은 자릿수) · 계류 횡단 1 곳 = 횡단 배수 구조물(관 ·
# 기슭막이) 400 ㎥.
COST_EARTH_M3 = 1.0
COST_LENGTH_M = 2.0
COST_CROSSING = 400.0
CROSSING_MERGE_M = 10.0 # 이 거리 안의 계류 교점은 한 횡단(갈라진 도엽 선 이음매)
REASON_TERRAIN = "지형상 불가능 — 복도 안 어떤 길도 {bound:.1f}% 이상 기울기를 지나야 이어짐"
REASON_GRADE_FIT = "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음"
@@ -33,6 +44,8 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을
REASON_FOLLOW_GRADE = "예상노선 추적 — 예상노선이 지나는 지반이 상한을 넘음(제약 탐색 안 함)"
REASON_FOLLOW_STREAM = "예상노선 추적 — 예상노선이 계류 버퍼 안을 지남(제약 탐색 안 함)"
REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음"
REASON_WEIGHTED_GRADE = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)"
REASON_WEIGHTED_STREAM = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(계류 버퍼 안을 지남)"
def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray:
@@ -113,17 +126,20 @@ def route_metrics(
bound_pct: float | None,
grade_smooth_m: float = GRADE_SMOOTH_M,
follow: bool = False,
weighted: bool = False,
target_grade: float | None = None,
) -> dict[str, Any]:
"""비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기.
`bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%).
`follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) — 사유가 그 뜻으로 뜬다.
`follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을
벌점으로만 따짐) — 사유가 그 뜻으로 뜬다. `target_grade` = 영선 노선 목표 기울기(비율 · 없으면
상한) — 기울기 편차(`grade_dev_pct`)의 기준.
"""
grid = (terrain.y, terrain.x)
sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None)
smooth = RegularGridInterpolator(
grid, smoothed_ground(terrain, grade_smooth_m), bounds_error=False, fill_value=None
)
smoothed = smoothed_ground(terrain, grade_smooth_m)
smooth = RegularGridInterpolator(grid, smoothed, bounds_error=False, fill_value=None)
points, chain = _resample(vertices, SAMPLE_M)
length = float(chain[-1])
lookup = points[:, ::-1]
@@ -149,7 +165,13 @@ def route_metrics(
float(ends[last]),
float(grades[first : last + 1].max()) * 100.0,
max_grade * 100.0,
REASON_FOLLOW_GRADE if follow else terrain_reason if relaxed else REASON_GRADE_FIT,
REASON_FOLLOW_GRADE
if follow
else REASON_WEIGHTED_GRADE
if weighted
else terrain_reason
if relaxed
else REASON_GRADE_FIT,
)
)
@@ -171,8 +193,12 @@ def route_metrics(
# 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의
# 버퍼 안 길이(45° 로 가로지름) + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이
route_points = shapely.points(points)
near = np.full(len(points), np.inf)
crossings = 0
if streams:
near = shapely.distance(route_points, MultiLineString(streams))
network = MultiLineString(streams)
near = shapely.distance(route_points, network)
crossings = _crossings(LineString(vertices), network)
for first, last in _runs(near < stream_offset_m):
ends_in = int(first == 0) + int(last == len(near) - 1)
crosses = float(near[first : last + 1].min()) <= SAMPLE_M
@@ -187,6 +213,8 @@ def route_metrics(
stream_offset_m,
REASON_FOLLOW_STREAM
if follow
else REASON_WEIGHTED_STREAM
if weighted
else REASON_STREAM_TERRAIN
if relaxed
else REASON_STREAM,
@@ -211,6 +239,17 @@ def route_metrics(
cut = float((np.maximum(depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
fill = float((np.maximum(-depth, 0.0).mean(axis=1) * road_width_m * weight).sum())
# 옆경사(고른 지반 · 노선에 수직) · 위험 통과 길이(옆경사 35° 이상 또는 계류 버퍼 안)
sy, sx = np.gradient(smoothed, _spacing(terrain.y), _spacing(terrain.x))
side = np.abs(
RegularGridInterpolator(grid, sx, bounds_error=False, fill_value=None)(lookup)
* -tangent[:, 1]
+ RegularGridInterpolator(grid, sy, bounds_error=False, fill_value=None)(lookup)
* tangent[:, 0]
)
hazard = float(weight[(side >= SAFE_SIDE_SLOPE) | (near < stream_offset_m)].sum())
target = max_grade if target_grade is None else target_grade
deviation = np.abs(grades - target)
offsets = shapely.distance(route_points, LineString(expected))
counts = {
kind: sum(1 for v in violations if v["kind"] == kind)
@@ -228,12 +267,39 @@ def route_metrics(
"raw_max_grade_pct": round(float(raw_grades.max()) * 100.0 if raw_grades.size else 0.0, 2),
"cut_m3": round(cut, 1),
"fill_m3": round(fill, 1),
# 흙 남음(절토 − 성토 · + = 남아 버릴 흙 · − = 모자라 들여올 흙) — 흙 균형 갈래의 목표
"surplus_m3": round(cut - fill, 1),
"over_limit": {**counts, "total": len(violations)},
"offset_avg_m": round(float(offsets.mean()), 2),
"offset_max_m": round(float(offsets.max()), 2),
# PLAN 23-5 — 옆경사 · 위험 통과 길이(안전 우선) · 계류 횡단(계류 횡단 최소) · 계류 평균
# 거리(계류 없으면 None) · 목표 기울기 편차(영선 노선 · 측점 기울기와 목표의 차 평균) ·
# 공사비 어림
"side_slope_avg_pct": round(float((side * weight).sum() / max(length, 1e-9)) * 100.0, 2),
"side_slope_max_pct": round(float(side.max()) * 100.0, 2),
"hazard_m": round(hazard, 1),
"stream_crossings": crossings,
"stream_offset_avg_m": round(float(near.mean()), 2) if streams else None,
"grade_dev_pct": round(
float((deviation * spans[keep]).sum() / spans[keep].sum()) * 100.0
if grades.size
else 0.0,
2,
),
"cost_index": round(
COST_EARTH_M3 * (cut + fill) + COST_LENGTH_M * length + COST_CROSSING * crossings
),
"violations": violations,
}
def _crossings(route: LineString, network: MultiLineString) -> int:
"""노선이 계류를 건너는 횟수 — 교점을 노선 따라 세고 `CROSSING_MERGE_M` 안은 한 번."""
hit = route.intersection(network)
points = [geom for geom in getattr(hit, "geoms", [hit]) if not geom.is_empty]
at = sorted(route.project(point.centroid) for point in points)
return sum(1 for i, value in enumerate(at) if i == 0 or value - at[i - 1] > CROSSING_MERGE_M)
def _spacing(coords: np.ndarray) -> float:
return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0
@@ -0,0 +1,285 @@
"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(PLAN 23-1 · 23-4).
`B05_Profile_Engine_RouteInitial` 이 부름.
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
쓸기 최대 기울기).
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
기울기 순한) 사이 범위로 나눠(`scale`) 비중 1 이 비슷한 크기로 움직이게.
· 제약은 막지 않는다 — 벌점 = (1 − 예상 몫) × 센 벌점(기울기 · 계류). 예상 100 이면 벌점 0
(추적 노선도 넘는 지형) · 예상 0 이면 갈래와 같은 세기. 넘은 곳은 `violations` 에 뜬다.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
NoRoute,
RouteGraph,
)
from common_util.common_util_route_polyline import build_planned_polyline
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
# 갈래 후보를 찾는 탐색 목표(PLAN 23-1 · 23-5) — 공사비는 상대 단가 그대로(`_solve`)
TARGETS: dict[str, dict[str, float]] = {
"earthwork": {**ZERO, "earthwork": 100.0},
"gentle": {**ZERO, "grade": 100.0},
"follow": {**ZERO, "expected": 100.0},
"gradeline": {"gradeline": 100.0},
"safe": {"risk": 100.0},
"crossings": {"crossing": 100.0},
"cost": {},
}
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
RELAXED_GRADE_COSTS = (50.0, 5.0)
# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음)
SWEEP_STEPS = 11
# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤
MIN_RANGE_SHARE = 0.05
# 두 노선 사이 가장 먼 거리가 이보다 작으면 「같은 노선」으로 알림(`similar` · PLAN 23-4)
SIMILAR_M = 10.0
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
CONSTRAINT_SHARE = 1.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
@dataclass
class Pool:
"""같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다."""
graph: RouteGraph
candidates: list[Route]
bound: float | None
limit: int # 후보로 남길 못 지킨 곳 수 상한
# 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히
scale: np.ndarray = field(default_factory=lambda: np.ones(5))
# 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는
# 가장 센 링크 기울기(`follow_cap`) · 사이는 예상 몫만큼(추적 쪽으로 갈수록 덜 막음)
base_cap: float = np.inf
follow_cap: float = np.inf
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
def _trace(
nodes: list[tuple[float, float]],
min_radius_m: float,
measure: Measure,
traced: dict[tuple, Route],
**kw: Any,
) -> Route:
# 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
key = (tuple(nodes), tuple(sorted(kw.items())))
if key not in traced:
outline = build_planned_polyline(
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
)
traced[key] = (outline, measure(outline, **kw))
return traced[key]
def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[float, float]]:
"""갈래 후보 탐색 한 번. 공사비 = 토공 가늠 · 계류 횡단 · 연장을 상대 단가 그대로 더한 비용
(링크 평균이 1 쯤 되게 한 값으로 나눔 — 제약 벌점과 겨루는 크기를 다른 목표와 맞춤)."""
if target != "cost":
return graph.solve(TARGETS[target], **constraint)
crossing = COST_CROSSING / (2.0 * CROSSING_BAND * graph.grid.cell) # 1 m 당(횡단 한 곳 몫)
per_m = COST_EARTH_M3 * float(np.average(graph.terms[1], weights=graph.length)) + COST_LENGTH_M
weights = {"earthwork": COST_EARTH_M3 / per_m, "crossing": crossing / per_m}
total = sum(weights.values())
return graph.solve(
weights,
scale=np.full(len(OBJECTIVES), 1.0 / total),
length_cost=COST_LENGTH_M / per_m,
**constraint,
)
def _candidates(
graph: RouteGraph,
max_grade: float,
min_radius_m: float,
measure: Measure,
traced: dict[tuple, Route],
) -> tuple[list[Route], float | None, int]:
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
"""
found: list[Route] = []
for target in TARGETS:
for step in GRADE_CAP_STEPS:
try:
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(_trace(nodes, min_radius_m, measure, traced, bound=None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
break
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None, least
# 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를
# 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다.
bound = graph.terrain_bound()
cap = bound + 1e-9
seed = _trace(
graph.solve(
{"expected": 50.0, "earthwork": 25.0, "grade": 25.0},
cap=cap,
strict_stream=False,
grade_cost=SOFT_GRADE_COST,
),
min_radius_m,
measure,
traced,
bound=bound,
)
found = [seed]
for target in TARGETS:
for cost in RELAXED_GRADE_COSTS:
nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost)
found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound))
most = seed[1]["over_limit"]["total"]
return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most
def build_pool(
graph: RouteGraph,
follow: Route,
max_grade: float,
min_radius_m: float,
measure: Measure,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
traced: dict[tuple, Route] = {}
candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced)
pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow)
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade)
pool.base_cap = (max_grade if bound is None else bound) + 1e-9
pool.follow_cap = max(steepest + 1e-9, pool.base_cap)
for k in np.linspace(0.0, 1.0, SWEEP_STEPS):
weights = {"expected": 0.0, "earthwork": 100.0 * k, "grade": 100.0 * (1.0 - k)}
route = weighted(pool, weights, min_radius_m, measure)
if route[1]["over_limit"]["total"] <= limit:
pool.candidates.append(route)
lines = list(
{id(outline): LineString(outline.vertices) for outline, _ in pool.candidates}.values()
)
pool.band_width_m = max(
(a.hausdorff_distance(b) for i, a in enumerate(lines) for b in lines[i + 1 :]), default=0.0
)
return pool
def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
here = LineString(outline.vertices)
others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}}
found = []
for name, (other, _) in others.items():
if name == mode or other is None:
continue
gap = here.hausdorff_distance(LineString(other.vertices))
if gap < SIMILAR_M:
found.append({"logic": name, "max_gap_m": round(gap, 1)})
return sorted(found, key=lambda item: item["max_gap_m"])
def _scale(
graph: RouteGraph, lines: list[list[tuple[float, float]]], max_grade: float
) -> tuple[np.ndarray, float]:
"""비중 몫의 크기(1 m 당) — 끝 노선 셋 사이 몫 적분 범위 / 추적 연장 · 추적의 가장 센 기울기.
비중 1 을 옮기면 몫마다 「끝 노선 사이 차이의 1 %」만큼 무게가 옮겨 가 몫끼리 영향이 비슷하다.
끝 노선끼리 그 몫이 거의 같으면(평지 등) 링크 평균으로 맞춤.
"""
measured = [graph.grid.objectives(line, limit=max_grade) for line in lines]
values = np.array([value for value, _ in measured])
reference = max(float(np.hypot(*np.diff(np.asarray(lines[0]), axis=0).T).sum()), 1.0)
spread = (values.max(axis=0) - values.min(axis=0)) / reference
per_link = np.vstack([graph.terms[:3], graph.over / graph.limit, graph.along])
mean = (per_link * graph.length).sum(axis=1) / max(float(graph.length.sum()), 1e-9)
return np.where(spread > MIN_RANGE_SHARE * mean, spread, mean), measured[0][1]
def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure: Measure) -> Route:
"""비중 계산 — 탐색 비용의 가중합 한 번(같은 비중은 다시 안 찾음). 제약은 벌점만."""
key = tuple(round(float(weights[name]), 6) for name in ZERO)
if key not in pool.searched:
share = weights["expected"] / max(sum(weights.values()), 1e-9)
soft = CONSTRAINT_SHARE * (1.0 - share)
nodes = pool.graph.solve(
weights,
cap=pool.base_cap + share * (pool.follow_cap - pool.base_cap),
strict_stream=False,
grade_cost=soft / max(pool.scale[3], 1e-12),
stream_cost=soft / max(pool.scale[4], 1e-12),
scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]),
)
pool.searched[key] = _trace(
nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True
)
return pool.searched[key]
def _earth(metrics: dict[str, Any]) -> float:
return metrics["cut_m3"] + metrics["fill_m3"]
GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
"earthwork": lambda m: (_earth(m),),
"gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]),
"gradeline": lambda m: (m["grade_dev_pct"], m["max_grade_pct"]),
"safe": lambda m: (m["hazard_m"], m["side_slope_avg_pct"]),
"crossings": lambda m: (m["stream_crossings"], -(m["stream_offset_avg_m"] or 0.0)),
"cost": lambda m: (m["cost_index"],),
}
def pick(mode: str, candidates: list[Route]) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보."""
goal = GOALS[mode]
return min(candidates, key=lambda item: goal(item[1]))
@@ -15,10 +15,15 @@ from dataclasses import dataclass
import numpy as np
from scipy import ndimage
from scipy.interpolate import RegularGridInterpolator
from scipy.sparse import csr_matrix
from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, smoothed_ground
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
GRADE_SMOOTH_M,
SAFE_SIDE_SLOPE,
smoothed_ground,
)
# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선).
ROAD_WIDTH_M = 4.0
@@ -30,7 +35,10 @@ TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·
SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기(지형상 불가능할 때만 · 1 m 당)
BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지)
STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°)
OBJECTIVES = ("expected", "earthwork", "grade")
# 탐색 목표(1 m 당) — 이격 · 토공 가늠 · 기울기 · 목표 기울기 편차(영선) · 위험(급한 옆경사 · 계류
# 가까움) · 계류 횡단(계류 선 위 링크) — PLAN 23-5
OBJECTIVES = ("expected", "earthwork", "grade", "gradeline", "risk", "crossing")
CROSSING_BAND = 0.75 # 계류 횡단 링크 = 링크 가운데가 계류에서 칸 × 이 값 안
# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림.
DIRECTIONS = (
(0, 1),
@@ -100,8 +108,10 @@ class SearchGrid:
*,
stream_offset_m: float,
grade_smooth_m: float = GRADE_SMOOTH_M,
target_grade: float | None = None,
) -> None:
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
self.target_grade = target_grade # 영선 노선 목표 기울기(비율) · 없으면 링크 상한
self.xs, self.ys = terrain.x[::step], terrain.y[::step]
# 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`)
self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step]
@@ -153,9 +163,64 @@ class SearchGrid:
# 반 간격(10 m) 동안 쌓는 높이 차 단면(지형상 불가능할 때만 0 이 아님)
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
offset = 0.5 * (self.d_expected[first][ok] + self.d_expected[second][ok])
terms = np.stack([offset, earth, (grade / limit) ** 2])
target = limit if self.target_grade is None else self.target_grade
stream_mid = 0.5 * (self.d_stream[first][ok] + self.d_stream[second][ok])
# 위험 = 비교값(`hazard_m`)과 같은 뜻 — 옆경사 35° 이상 · 계류 버퍼 안을 지나는 길이(칸
# 두 끝 가운데 급한 쪽) · 옆경사 몫을 조금 더해 같은 길이면 덜 급한 쪽
risk = ((cross >= SAFE_SIDE_SLOPE) | (stream_mid < self.stream_offset_m)).astype(
np.float64
) + 0.1 * (cross / SAFE_SIDE_SLOPE) ** 2
crossing = (stream_mid < CROSSING_BAND * self.cell).astype(np.float64)
terms = np.stack(
[offset, earth, (grade / limit) ** 2, ((grade - target) / limit) ** 2, risk, crossing]
)
return a[ok], b[ok], self.cell * norm, terms, over, along, grade
def objectives(
self, vertices: list[tuple[float, float]], *, limit: float
) -> tuple[np.ndarray, float]:
"""폴리라인 하나의 몫 적분(`links` 의 1 m 당 값 × 길이)과 지나는 가장 센 링크 기울기.
몫 = 이격 · 토공 가늠 · 기울기 · 넘은 기울기(/ 상한) · 계류 버퍼 안 나란히(m) — 칸 길이
간격으로 잰다. 비중 계산의 크기 맞춤(끝 노선 셋 사이 범위)에 쓴다 — 탐색 경로가 아닌
선(추적)도 잰다.
"""
xy = np.asarray(vertices, dtype=np.float64)
chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
at = np.linspace(0.0, chain[-1], max(2, int(chain[-1] / self.cell) + 1))
points = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])])
mid = 0.5 * (points[1:] + points[:-1])
step = np.diff(points, axis=0)
length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9)
def sample(field: np.ndarray, where: np.ndarray) -> np.ndarray:
field = np.where(np.isfinite(field), field, 1e9)
return RegularGridInterpolator(
(self.ys, self.xs), field, bounds_error=False, fill_value=None
)(where[:, ::-1])
grade = np.abs(np.diff(sample(self.z, points))) / length
over = np.maximum(0.0, grade - limit)
normal = np.column_stack([-step[:, 1], step[:, 0]]) / length[:, None]
cross = np.abs(
sample(self.grad_x, mid) * normal[:, 0] + sample(self.grad_y, mid) * normal[:, 1]
)
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0
away = np.column_stack([sample(self.away_x, mid), sample(self.away_y, mid)])
size = np.hypot(away[:, 0], away[:, 1])
across = np.abs((away * step).sum(axis=1)) >= STREAM_CROSS_COS * length * size
along = (sample(self.d_stream, mid) < self.stream_offset_m) & ~across & (size > 1e-6)
terms = np.stack(
[
np.minimum(sample(self.d_expected, mid), CORRIDOR_M),
earth,
(grade / limit) ** 2,
over / limit,
along.astype(np.float64),
]
)
return (terms * length).sum(axis=1), float(grade.max(initial=0.0))
def _turn_steps(min_radius_m: float, cell: float) -> int:
"""꺾은 뒤 곧게 가야 할 칸 수 — 앞뒤 원호 접선이 한 직선에 들어가게.
@@ -226,6 +291,7 @@ class RouteGraph:
shape=(self.size, self.size),
)
self.indices, self.indptr = order.indices, order.indptr
self._frame: tuple | None = None # 마지막으로 쓴 간선 틀(`_graph`)
edge = (order.data - 1.0).astype(np.int64)
self.edge_link = link_of[edge]
self.edge_turn = np.concatenate(turn)[edge]
@@ -243,15 +309,22 @@ class RouteGraph:
return usable & ~self.along if strict_stream else usable
def _graph(self, usable: np.ndarray, link_cost: np.ndarray | None) -> csr_matrix:
"""쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`)."""
keep = usable[self.edge_link]
indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr]
indptr = np.append(indptr, indptr[-1] + len(self.starts))
indices = np.concatenate([self.indices[keep], self.starts])
"""쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`).
틀(남길 간선 · 행 포인터)은 같은 `usable` 이면 다시 쓴다 — 후보 묶음은 같은 상한으로
여러 번 찾는다(비용만 다름)."""
signature = usable.tobytes()
if self._frame is None or self._frame[0] != signature:
keep = usable[self.edge_link]
indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr]
indptr = np.append(indptr, indptr[-1] + len(self.starts))
indices = np.concatenate([self.indices[keep], self.starts])
self._frame = (signature, keep, indptr, indices, self.edge_link[keep])
_, keep, indptr, indices, links = self._frame
if link_cost is None:
data = np.ones(indices.size)
else:
cost = link_cost[self.edge_link[keep]] + self.edge_turn[keep]
cost = link_cost[links] + self.edge_turn[keep]
data = np.concatenate([cost, np.full(len(self.starts), 1e-9)])
return csr_matrix((data, indices, indptr), shape=(self.size + 1, self.size + 1))
@@ -268,24 +341,31 @@ class RouteGraph:
cap: float,
strict_stream: bool,
grade_cost: float = 0.0,
stream_cost: float = SOFT_STREAM_COST,
scale: np.ndarray | None = None,
length_cost: float = LENGTH_COST,
) -> list[tuple[float, float]]:
"""노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로.
세 목표는 쓸 수 있는 링크 평균이 1 이 되게 정규화(길이 가중)한 뒤 비중을 곱한다.
`grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) — `cap` 이 상한보다
클 때(지형상 불가능)만 뜻이 있다.
목표(`OBJECTIVES`)는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는
링크 평균이 1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은
비율 / 상한) ·
`stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) ·
`length_cost` = 1 m 당 거리 비용.
"""
usable = self._usable(cap, strict_stream)
if not usable.any():
raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.")
length = self.length[usable]
scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9)
share = np.array([weights[key] for key in OBJECTIVES]) / max(sum(weights.values()), 1e-9)
if scale is None:
length = self.length[usable]
scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9)
share = np.array([weights.get(key, 0.0) for key in OBJECTIVES])
share = share / max(sum(weights.values()), 1e-9)
factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12)
penalty = grade_cost * self.over / self.limit
if not strict_stream:
penalty = penalty + SOFT_STREAM_COST * self.along
link_cost = self.length * (LENGTH_COST + factor @ self.terms + penalty)
penalty = penalty + stream_cost * self.along
link_cost = self.length * (length_cost + factor @ self.terms + penalty)
distance, predecessors = dijkstra(
self._graph(usable, link_cost), indices=self.size, return_predecessors=True
)
+7 -4
View File
@@ -105,14 +105,17 @@ class RouteInitialCriteria(BaseModel):
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted."""
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field(
"follow", validation_alias=AliasChoices("logic", "mode")
)
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
@@ -70,6 +70,7 @@ async def initial_route(
radius_m = criteria.min_radius_m
stream_offset_m = criteria.stream_offset_m if criteria else None
grade_smooth_m = criteria.grade_smooth_m if criteria else None
target_pct = criteria.target_grade_pct if criteria else None
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
@@ -90,6 +91,7 @@ async def initial_route(
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
target_grade=None if target_pct is None else target_pct / 100.0,
)
)
except (FileNotFoundError, ValueError) as error:
@@ -27,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
initialRouteSnapshot,
normalizeRouteInitial,
readInitialChoice,
@@ -85,6 +86,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
@@ -130,6 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
criteria: choice?.criteriaOverrides ?? base.criteria,
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: opening!.planned.map(([x, y]) => [x, y]),
@@ -17,8 +17,12 @@ type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추적",
gradeline: "영선 노선",
earthwork: "토공 최소",
gentle: "기울기 순한",
safe: "안전 우선",
crossings: "계류 횡단 최소",
cost: "공사비 어림 최소",
weighted: "비중 계산",
};
@@ -36,6 +40,12 @@ export const CRITERIA_KEYS = [
] as const;
export type CriteriaKey = (typeof CRITERIA_KEYS)[number];
/** 영선 노선 목표 기울기 칸 — 공통 제약 네 칸과 따로(영선을 고를 때만 보이고 보낸다). */
export const TARGET_GRADE_KEY = "target_grade_pct" as const;
/** 겹침 값에 담길 수 있는 모든 키 — 캐시 · 서버 기억 견주기에 쓴다. */
const OVERRIDE_KEYS = [...CRITERIA_KEYS, TARGET_GRADE_KEY] as const;
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
export function rebalanceWeights(
weights: RouteInitialWeights,
@@ -51,6 +61,68 @@ export function rebalanceWeights(
return { expected: out.expected, earthwork: out.earthwork, grade: out.grade };
}
/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */
export type WeightLocks = Record<WeightKey, boolean>;
export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false };
const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]);
/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */
export function derivedWeightKey(locks: WeightLocks): WeightKey | null {
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
return free.length === 1 ? free[0] : null;
}
/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김).
* 이미 고정한 것을 푸는 것은 늘 된다. */
export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean {
return locks[key] || lockedKeys(locks).length < 2;
}
/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다.
* 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을
* 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */
export function moveWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): RouteInitialWeights {
if (locks[key]) return { ...weights };
const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const room = 100 - fixedSum;
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value);
const out = { ...weights };
if (free.length === 1) {
out[key] = room;
return out;
}
const other = free.find((name) => name !== key)!;
const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
out[key] = next;
out[other] = room - next;
return out;
}
/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다.
* 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */
export function inputWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): { ok: boolean; weights: RouteInitialWeights } {
const same = { ok: false, weights: { ...weights } };
if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same;
const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same;
if (value > room) return same;
return { ok: true, weights: moveWeight(weights, locks, key, value) };
}
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
export interface InitialMetrics {
lengthM: number | null;
@@ -58,6 +130,20 @@ export interface InitialMetrics {
meanGradePct: number | null;
cutM3: number | null;
fillM3: number | null;
/** 옆경사(지형이 노선 옆으로 기운 정도, %) 평균 · 최대. */
sideSlopeAvgPct: number | null;
sideSlopeMaxPct: number | null;
/** 위험 구간(옆경사 35° 이상 · 계류 버퍼 안)을 지나는 길이(m). */
hazardM: number | null;
/** 계류를 건너는 횟수 · 계류와의 평균 이격(m). */
streamCrossings: number | null;
streamOffsetAvgM: number | null;
/** 측점 기울기와 영선 목표 기울기 차의 평균(%p). */
gradeDevPct: number | null;
/** 공사비 어림 — 절성토 · 연장 · 계류 횡단을 섞은 **상대값**(단위 없음 · 실제 공사비 아님). */
costIndex: number | null;
/** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */
surplusM3: number | null;
overLimitCount: number | null;
offsetM: number | null;
/** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */
@@ -80,6 +166,8 @@ export interface InitialCriteria {
streamOffsetM: number | null;
/** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
gradeSmoothM: number | null;
/** 영선 노선이 실제로 쓴 목표 기울기(%) — 서버 `target_grade_pct`. */
targetGradePct: number | null;
}
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
@@ -93,6 +181,16 @@ export interface InitialChoice {
criteriaOverrides: RouteInitialCriteria | null;
/** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
terrainBoundPct?: number | null;
/** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */
similar?: SimilarRoute[] | null;
/** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */
bandWidthM?: number | null;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
export interface SimilarRoute {
logic: RouteInitialLogic;
gapM: number;
}
const num = (value: unknown): number | null =>
@@ -167,6 +265,19 @@ export function mapPaths(
}));
}
/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */
function parseSimilar(raw: unknown): SimilarRoute[] | null {
if (!Array.isArray(raw)) return null;
const out: SimilarRoute[] = [];
for (const item of raw as Array<Record<string, unknown>>) {
const logic = typeof item?.logic === "string" ? item.logic : "";
const gap = num(item?.max_gap_m);
if (logic in LOGIC_LABEL && gap !== null)
out.push({ logic: logic as RouteInitialLogic, gapM: gap });
}
return out;
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
@@ -195,6 +306,14 @@ export function normalizeRouteInitial(
meanGradePct: pick(m, "avg_grade_pct"),
cutM3: pick(m, "cut_m3"),
fillM3: pick(m, "fill_m3"),
sideSlopeAvgPct: pick(m, "side_slope_avg_pct"),
sideSlopeMaxPct: pick(m, "side_slope_max_pct"),
hazardM: pick(m, "hazard_m"),
streamCrossings: pick(m, "stream_crossings"),
streamOffsetAvgM: pick(m, "stream_offset_avg_m"),
gradeDevPct: pick(m, "grade_dev_pct"),
costIndex: pick(m, "cost_index"),
surplusM3: pick(m, "surplus_m3"),
overLimitCount: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
violations: Array.isArray(m.violations)
@@ -209,12 +328,15 @@ export function normalizeRouteInitial(
}
: null,
terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
similar: parseSimilar(body.similar),
bandWidthM: num(body.band_width_m),
criteria: body.criteria
? {
maxGradePct: pick(c, "max_grade_pct"),
minRadiusM: pick(c, "min_radius_m"),
streamOffsetM: pick(c, "stream_offset_m"),
gradeSmoothM: pick(c, "grade_smooth_m"),
targetGradePct: pick(c, "target_grade_pct"),
}
: null,
criteriaOverrides,
@@ -235,7 +357,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const out: RouteInitialCriteria = {};
for (const key of CRITERIA_KEYS) {
for (const key of OVERRIDE_KEYS) {
const value = num(source[key]);
if (value !== null) out[key] = value;
}
@@ -244,7 +366,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
/** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */
function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean {
return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
return OVERRIDE_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
}
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */
@@ -289,26 +411,94 @@ const fixed = (value: number | null, digits: number, unit: string): string =>
? "—"
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
export function metricsLine(metrics: InitialMetrics | null): string {
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
return [
/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */
export function surplusText(surplusM3: number): string {
const rounded = Math.round(surplusM3);
if (rounded === 0) return "0㎥(균형)";
const sign = rounded > 0 ? "+" : "−";
return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`;
}
/** 조사 「과 / 와」 — 받침이 있으면 과. */
const withParticle = (name: string): string => {
const code = name.charCodeAt(name.length - 1);
const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
return `${name}${hasFinal ? "과" : "와"}`;
};
/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다).
* 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */
export function similarLine(choice: InitialChoice | null): string {
const similar = choice?.similar;
if (!choice || !similar || similar.length === 0) return "";
const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · ");
const gap = Math.max(...similar.map((item) => item.gapM));
const band =
choice.bandWidthM === null || choice.bandWidthM === undefined
? ""
: `폭 ${fixed(choice.bandWidthM, 1, "m")} `;
return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`;
}
/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */
export const SIMILAR_HINT =
"기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈).";
/** 결과 비교값 — 두 묶음. 노선 · 기울기 / 흙 · 안전 · 비용. 서버가 안 준 칸(옛 답)은 빠진다. */
export interface MetricsLines {
route: string;
work: string;
}
const NO_METRICS = "비교값 — 알약을 누르면 서버가 셈해 보입니다.";
export function metricsLines(metrics: InitialMetrics | null): MetricsLines {
if (!metrics) return { route: NO_METRICS, work: "" };
const has = (value: number | null): value is number => value !== null;
const route = [
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
...(has(metrics.gradeDevPct)
? [`목표 기울기 편차 ${fixed(metrics.gradeDevPct, 1, "%p")}`]
: []),
...(has(metrics.sideSlopeAvgPct)
? [
`옆경사 평균 ${fixed(metrics.sideSlopeAvgPct, 1, "%")} · 최대 ${fixed(metrics.sideSlopeMaxPct, 1, "%")}`,
]
: []),
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
`예상노선과 이격 ${fixed(metrics.offsetM, 1, "m")}`,
].join(" · ");
const work = [
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
...(has(metrics.surplusM3) ? [`남는 흙 ${surplusText(metrics.surplusM3)}`] : []),
...(has(metrics.hazardM) ? [`위험 구간 통과 ${fixed(metrics.hazardM, 0, "m")}`] : []),
...(has(metrics.streamCrossings) ? [`계류 횡단 ${metrics.streamCrossings}회`] : []),
...(has(metrics.streamOffsetAvgM)
? [`계류 이격 평균 ${fixed(metrics.streamOffsetAvgM, 1, "m")}`]
: []),
...(has(metrics.costIndex)
? [`공사비 어림 ${fixed(metrics.costIndex, 0, "")}(어림 · 상대값)`]
: []),
].join(" · ");
return { route, work };
}
/** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
export function criteriaLine(
criteria: InitialCriteria | null,
planMinRadiusM: number,
/** 영선 노선일 때만 — 서버가 쓴 목표 기울기를 함께 보인다. */
showTarget = false,
): string {
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
const parts = [
`종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`,
`최소 R ${fixed(radius, 0, "m")}`,
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
];
if (showTarget && criteria?.targetGradePct != null)
parts.push(`영선 목표 기울기 ${fixed(criteria.targetGradePct, 1, "%")}`);
// 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다.
if (criteria?.gradeSmoothM)
parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`);
+142 -19
View File
@@ -23,9 +23,15 @@ import {
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
metricsLine,
rebalanceWeights,
metricsLines,
TARGET_GRADE_KEY,
canLockWeight,
derivedWeightKey,
inputWeight,
moveWeight,
relaxedLine,
SIMILAR_HINT,
similarLine,
violationsLine,
violationText,
WEIGHT_KEYS,
@@ -33,6 +39,7 @@ import {
type InitialChoice,
type InitialCriteria,
type MapLayer,
type WeightLocks,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
@@ -40,12 +47,25 @@ import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"];
const PILLS: RouteInitialLogic[] = [
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
];
const PILL_TITLE: Record<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음",
safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
crossings: "계류를 건너는 횟수가 가장 적은 노선",
cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
};
@@ -55,6 +75,11 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
grade: "기울기",
};
/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */
const LOCK_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3.5" y="7" width="9" height="6.5" rx="1.2" /><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" /></svg>`;
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
@@ -92,6 +117,8 @@ export interface RecommendParams {
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
defaults: Partial<Record<CriteriaKey, number>>;
weights: RouteInitialWeights;
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -108,7 +135,10 @@ export interface RecommendParams {
export function openRecommendModal(params: RecommendParams): void {
let logic: RouteInitialLogic = "follow";
let weights: RouteInitialWeights = { ...params.weights };
const { locks } = params.weightMemory;
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
const invalid = new Set<WeightKey>();
let preview: InitialChoice | null = null;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
@@ -156,12 +186,14 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__weights" hidden>
<div class="b05-routerec__bars">
${WEIGHT_KEYS.map(
(
key,
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
(key) => `<div class="b05-routerec__bar">
<input type="number" class="b05-routerec__num" min="0" max="100" step="1"
inputmode="numeric" data-weight-num="${key}" aria-label="${WEIGHT_NAME[key]} 비중 값" />
<input type="range" min="0" max="100" step="1" data-weight="${key}"
aria-label="${WEIGHT_NAME[key]} 비중" />
<span>${WEIGHT_NAME[key]}</span></label>`,
<button type="button" class="b05-routerec__lock" data-weight-lock="${key}"
aria-pressed="false" aria-label="${WEIGHT_NAME[key]} 비중 고정">${LOCK_ICON}</button>
<span>${WEIGHT_NAME[key]}</span></div>`,
).join("")}
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
@@ -173,13 +205,20 @@ export function openRecommendModal(params: RecommendParams): void {
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</div>
<label class="b05-routerec__target" data-target-row hidden
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
<input type="number" inputmode="decimal" step="0.1" min="0" max="100"
data-criteria="${TARGET_GRADE_KEY}" placeholder="상한" />%</label>
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
</div>
</div>
<div class="b05-routerec__lines">
<div data-line="metrics"></div>
<div data-line="metrics-work"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="similar" hidden></div>
<div class="is-dim" data-line="similar-hint" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
@@ -199,7 +238,8 @@ export function openRecommendModal(params: RecommendParams): void {
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
criteriaInputs.forEach((input) => {
const value = params.criteria?.[input.dataset.criteria as CriteriaKey];
const value =
params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY];
input.value = value === undefined ? "" : String(value);
});
@@ -207,10 +247,13 @@ export function openRecommendModal(params: RecommendParams): void {
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
// 목표 기울기는 영선 노선을 물을 때만 보낸다.
if (key === TARGET_GRADE_KEY && logic !== "gradeline") return;
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
if (Number.isFinite(value)) out[key] = value;
});
return out;
}
@@ -232,26 +275,55 @@ export function openRecommendModal(params: RecommendParams): void {
pill.disabled = busy;
});
weightsBox.hidden = logic !== "weighted";
const derived = derivedWeightKey(locks);
for (const key of WEIGHT_KEYS) {
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
const slider = q<HTMLInputElement>(`[data-weight="${key}"]`);
const num = q<HTMLInputElement>(`[data-weight-num="${key}"]`);
const lock = q<HTMLButtonElement>(`[data-weight-lock="${key}"]`);
// 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다.
const shut = locks[key] || derived === key;
slider.value = String(weights[key]);
slider.disabled = busy || shut;
// 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다.
if (!invalid.has(key)) num.value = String(weights[key]);
num.disabled = busy || shut;
num.classList.toggle("is-invalid", invalid.has(key));
num.setAttribute("aria-invalid", String(invalid.has(key)));
lock.setAttribute("aria-pressed", String(shut));
lock.classList.toggle("is-on", shut);
lock.classList.toggle("is-auto", derived === key);
// 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다.
lock.disabled = busy || !canLockWeight(locks, key) || derived === key;
lock.title =
derived === key
? "나머지 둘이 고정돼 저절로 정해집니다"
: locks[key]
? "고정 풀기"
: "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다";
}
q<HTMLElement>("[data-weight-sum]").textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
// 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시.
const known =
key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key];
input.placeholder =
params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값";
known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값";
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
rerun.hidden = !dirty;
rerun.disabled = busy;
applyButton.disabled = busy || dirty || !preview;
rerun.disabled = busy || invalid.size > 0;
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
const shown = metricsLines(preview?.metrics ?? null);
line("metrics").textContent = preview
? preview.metrics
? metricsLine(preview.metrics)
? shown.route
: "비교값 — 서버 답에 없습니다."
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
line("metrics-work").textContent = preview ? shown.work : "";
line("metrics-work").hidden = !line("metrics-work").textContent;
const violations = violationsLine(preview?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (preview?.metrics?.violations ?? [])
@@ -261,14 +333,23 @@ export function openRecommendModal(params: RecommendParams): void {
const relaxed = relaxedLine(preview);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
const similar = similarLine(preview);
line("similar").textContent = similar;
line("similar").hidden = !similar;
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
line("similar-hint").hidden = !similar;
line("criteria").textContent = preview
? criteriaLine(preview.criteria, params.planMinRadiusM)
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
: "";
line("criteria").hidden = !preview;
drawMap();
}
async function run(): Promise<void> {
if (logic === "weighted" && invalid.size > 0) {
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
return;
}
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
@@ -314,17 +395,59 @@ export function openRecommendModal(params: RecommendParams): void {
void run();
});
});
const remember = (): void => {
params.weightMemory.weights = { ...weights };
};
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(
weights = moveWeight(
weights,
locks,
input.dataset.weight as WeightKey,
Number(input.value),
);
invalid.clear();
remember();
dirty = preview !== null;
render();
});
});
// 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리.
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((input) => {
input.addEventListener("input", () => {
const key = input.dataset.weightNum as WeightKey;
const raw = input.value.trim();
const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw));
if (result.ok) {
weights = result.weights;
invalid.clear();
remember();
dirty = preview !== null;
// 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다.
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((other) => {
if (other !== input)
other.value = String(weights[other.dataset.weightNum as WeightKey]);
});
} else {
invalid.add(key);
}
render();
});
// 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70).
input.addEventListener("blur", () => {
if (!invalid.has(input.dataset.weightNum as WeightKey)) render();
});
});
// 고정 단추 — 지금 값을 붙든다. 셋 다 고정은 막혀 있다(단추가 꺼짐).
body.querySelectorAll<HTMLButtonElement>("[data-weight-lock]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.weightLock as WeightKey;
if (!canLockWeight(locks, key)) return;
locks[key] = !locks[key];
invalid.clear();
render();
});
});
criteriaInputs.forEach((input) => {
input.addEventListener("input", () => {
dirty = preview !== null;
@@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field } from "./B05_Profile_UI_Structures_Fields";
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
import { openItemPicker } from "@ui/ui_template_item_picker";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -501,6 +502,10 @@ export function createStructuresSection(
current: summary.get(),
canCopy: true,
projectId: sectionOptions.projectId ?? null,
// 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만)
structureId:
editingId ??
(selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null),
}).then((result) => {
if (result.action === "cancel") return;
summary.set(result.action === "apply" ? result.item : null);
@@ -26,14 +26,32 @@
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
.b05-routerec__pills {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-8);
}
/* 좁은 폭 — 두 줄을 지키려 글자를 줄이고, 글자가 잘릴 만큼 좁으면(560px 이하) 두 칸 네 줄. */
@media (max-width: 720px) {
.b05-routerec__pill {
padding-inline: 4px;
font-size: var(--text-body-sm);
}
}
@media (max-width: 560px) {
.b05-routerec__pills {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.b05-routerec__pill {
padding: 8px var(--spacing-16);
padding: 8px var(--spacing-8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
@@ -163,13 +181,73 @@
accent-color: var(--color-primary);
}
.b05-routerec__bar output {
/* 값 칸 — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */
.b05-routerec__num {
width: 100%;
min-width: 3.2em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body);
font-weight: 600;
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.b05-routerec__bar span {
.b05-routerec__num::-webkit-outer-spin-button,
.b05-routerec__num::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.b05-routerec__num:disabled {
opacity: 0.6;
}
.b05-routerec__num.is-invalid {
border-color: var(--color-danger, #dc2626);
box-shadow: 0 0 0 1px var(--color-danger, #dc2626);
}
/* 고정 단추 — 자물쇠. 눌린 것 = 고정 · 저절로 정해진 비중은 눌린 채 꺼져(잠김 표시). */
.b05-routerec__lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routerec__lock:hover:not(:disabled) {
border-color: var(--color-primary);
}
.b05-routerec__lock.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
}
.b05-routerec__lock:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.b05-routerec__lock.is-auto {
opacity: 0.8;
border-style: dashed;
}
.b05-routerec__bar > span {
color: var(--color-text-secondary);
}
@@ -185,7 +263,8 @@
gap: var(--spacing-8);
}
.b05-routerec__criteria label {
.b05-routerec__criteria label,
.b05-routerec__target {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
align-items: center;
@@ -193,7 +272,8 @@
color: var(--color-text-body);
}
.b05-routerec__criteria input {
.b05-routerec__criteria input,
.b05-routerec__target input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--color-border);
@@ -204,7 +284,8 @@
text-align: right;
}
.b05-routerec__criteria input::placeholder {
.b05-routerec__criteria input::placeholder,
.b05-routerec__target input::placeholder {
color: var(--color-text-secondary);
}
@@ -221,6 +302,13 @@
line-height: 1.5;
}
/* 같은 노선 알림 아래 흐린 글. */
.b05-routerec__lines .is-dim {
color: var(--color-text-secondary);
font-size: var(--text-caption);
opacity: 0.8;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
.b05-routerec__lines .is-warn {
color: var(--color-warning);
@@ -251,3 +339,7 @@
height: 300px;
}
}
.b05-routerec__target[hidden] {
display: none;
}
+97 -22
View File
@@ -1,8 +1,11 @@
"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b).
"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산
(PLAN 25-2b · 26-2b).
복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 —
쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움.
옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음).
복사본 = `{프로젝트}/templates/structures/{구조물 id}/`
(`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id.
파일마다 판 = 내용 해시 — 쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 지울 때
`_지난/` 에 옮겨 둠. 옛 복사본(새 파일 없음) · 옛 항목 폴더는 읽을 때 채우고 옮겨 받음.
계산은 복사본만 씀(마스터 현재값 안 읽음) — 원본 항목이 바뀌거나 지워져도 그대로.
"""
from __future__ import annotations
@@ -13,17 +16,25 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import snapshot_folder_name
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Snapshot import (
FOLDER_PATTERN,
LOCAL_FILES,
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
build_snapshot,
complete_old,
folder_name,
items_dir,
migrate_items,
now_text,
retire,
same_item,
source_files,
structures_dir,
write_snapshot,
)
from M02_MasterTemplete.M02_Item_Source import (
@@ -48,7 +59,6 @@ KINDS = {
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
SEATS = ("면적", "재료", "선택", None)
_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$")
_MT = re.compile(r"^MT\d{6}$")
@@ -58,11 +68,18 @@ class Stale(ValueError):
self.current = current
def _migrated(project_root: str | Path) -> Path:
"""구조물 복사본 뿌리 — 옛 항목 폴더가 남았으면 먼저 옮겨 받음."""
if items_dir(project_root).is_dir():
migrate_items(project_root)
return structures_dir(project_root)
def folder_of(project_root: str | Path, folder: str) -> Path:
"""복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
if not _FOLDER.match(folder):
"""구조물 복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
if not FOLDER_PATTERN.match(folder):
raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」")
target = items_dir(project_root) / folder
target = _migrated(project_root) / folder
if not (target / MANIFEST_NAME).is_file():
raise LookupError(f"복사본이 없음 「{folder}」")
complete_old(target)
@@ -79,10 +96,10 @@ def source_changed(manifest: dict[str, Any]) -> bool | None:
def list_items(project_root: str | Path) -> list[dict[str, Any]]:
base = items_dir(project_root)
base = _migrated(project_root)
out = []
for child in sorted(base.iterdir()) if base.is_dir() else []:
if not child.is_dir() or not _FOLDER.match(child.name):
if not child.is_dir() or not FOLDER_PATTERN.match(child.name):
continue
if not (child / MANIFEST_NAME).is_file():
continue
@@ -101,6 +118,7 @@ def list_items(project_root: str | Path) -> list[dict[str, Any]]:
"원본없음": changed is None,
"구조물": manifest.get("구조물") or [],
"고침": bool(manifest.get("고침")),
"값있음": bool(manifest.get("값있음")),
}
)
return out
@@ -215,12 +233,15 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
return out
def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
"""층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림."""
def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
"""재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림.
원본과 달라 덮인 파일(도면 · 면적 · 재료 · 자재조합 · 입력연결)은 `덮임` 에 알림."""
target = folder_of(project_root, folder)
manifest = read_json(target / MANIFEST_NAME) or {}
values = read_json(target / VALUES_FILE)
picks = read_json(target / PICKS_FILE)
before = {name: read_json(target / file) for name, file in KINDS.items()}
files = build_snapshot(manifest["원본"])
labor = files.get(LABOR_FILE) or {}
files[VALUES_FILE] = _kept_values(values, labor)
@@ -230,24 +251,78 @@ def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
old.unlink()
for name, content in files.items():
atomic_write_json(target / name, content)
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
covered = [
name
for name, file in KINDS.items()
if file not in LOCAL_FILES and before[name] != files.get(file)
]
source = version_of(source_files(files))
stamp = now_text()
fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp}
# 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림
kept = bool(files[VALUES_FILE] or files[PICKS_FILE])
fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False}
fresh["값있음"] = kept
fresh.pop("고친날", None)
atomic_write_json(target / MANIFEST_NAME, fresh)
return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]}
return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered, "값있음": kept}
def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]:
"""구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음)."""
folder = snapshot_folder_name(item)
target = items_dir(project_root) / folder
refresh = reapply # 25-1b 옛 이름(새로 가져오기)
def prefetch(project_root: str | Path, owner_id: str, item: dict[str, Any]) -> dict[str, Any]:
"""구조물 복사본을 미리 가져옴(아직 [저장] 전) — 같은 항목이면 그대로 · 다르면 새로.
[저장] 뒤에도 그 구조물이 이 항목을 안 쓰면 맞추기가 지움(고친 흔적이 있으면 `_지난/`)."""
folder = folder_name(owner_id)
if not FOLDER_PATTERN.match(folder):
raise ValueError(f"구조물 id 모양이 아님 「{owner_id}」")
target = _migrated(project_root) / folder
manifest = read_json(target / MANIFEST_NAME)
made = not isinstance(manifest, dict)
made = not (isinstance(manifest, dict) and same_item(manifest, item))
if made:
manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()})
if target.is_dir():
retire(target)
manifest = write_snapshot(target, item, owner_id, {"미리가져옴": now_text()})
return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")}
def save_as(
project_root: str | Path, folder: str, company: int, user: int, path: list[str], name: str
) -> dict[str, Any]:
"""[개인에 저장] — 복사본 내용으로 개인 층에 새 항목(단가 · 입력값 · 고른자재는 안 가져감)."""
target = folder_of(project_root, folder)
manifest = read_json(target / MANIFEST_NAME) or {}
made = personal.create_item(
personal.personal_folder(company, user),
path,
name,
None,
copy=target,
origin=manifest.get("원본"),
)
return {**made, "summary_item": personal.personal_item(company, user, made)}
def new_item(
project_root: str | Path,
owner_id: str,
company: int,
user: int,
path: list[str],
name: str,
unit: str | None,
) -> dict[str, Any]:
"""프로젝트에서 새 항목 — 개인 층에 빈 항목을 만들고 그 구조물 복사본으로.
돌려준 `summary_item` 을 화면이 그 구조물에 지정해 [저장] 해야 복사본이 남음(안 하면 맞추기가
다른 항목 · 지정 없음으로 보고 지움)."""
made = personal.create_item(personal.personal_folder(company, user), path, name, unit)
item = personal.personal_item(company, user, made)
got = prefetch(project_root, owner_id, item)
return {**made, "폴더": got["폴더"], "summary_item": item}
def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
"""복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음."""
target = folder_of(project_root, folder)
+128
View File
@@ -0,0 +1,128 @@
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json
#: 복사본 파일 → 층 양식 종류
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
def heads_of(path: list[str], name: str | None) -> list[str]:
"""경로 + 이름 → 항목 경로(깊이 자유 · 빈 칸은 뺌) — 머리 줄 수는 집계표 저장이 맞춤."""
full = [str(p).strip() for p in [*path, name or ""] if str(p or "").strip()]
if not full:
raise ValueError("새 항목의 경로 · 이름이 비어 있습니다.")
return full
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
(나무는 같은 경로 두 항목을 안 둠)."""
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
out, n = list(full), 2
while out in taken:
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
return out
def _new_matset(row: dict[str, Any], fallback: str) -> str:
"""자재 조합 줄을 이 층에 새 키로 — 이름이 겹치면 「이름 (2)」."""
taken = {str(r.get("이름") or "") for r in matset.listing()["줄"]}
name = base = str(row.get("이름") or fallback)
n = 2
while name in taken:
name, n = f"{base} ({n})", n + 1
got = {k: row.get(k) for k in ("담은자재", "소유", "비고")}
return matset.create({**got, "이름": name, "본뜬키": None})["키"]
def create_item(
folder: Path,
path: list[str],
name: str | None,
unit: str | None,
copy: Path | None = None,
origin: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""개인 층 `folder` 에 새 항목 — `copy` = 구조물 복사본 폴더(있으면 그 내용을 담음).
돌려줌 `{key, 열, 이름}`."""
heads = heads_of(path, name)
total = read_json(copy / "집계.json") if copy else None
base_col = dict((total or {}).get("열") or {})
base_row = (total or {}).get("줄") or {}
items_store.ensure(folder)
with store.at(folder):
table = store.read("table", store.STRUCTURE_TABLE)
doc = table["문서"]
used = {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}
new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used)
um_row = read_json(copy / UM_FILE) if copy else None
um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None
heads = _free_path(doc, heads)
column = {
**json.loads(json.dumps(base_col)),
"id": new_id,
"머리": heads,
"경로": heads,
"꼴": "수",
"단위": unit or base_col.get("단위") or base_row.get("단위"),
"일위대가": base_col.get("일위대가") or base_row.get("일위대가"),
"자재조합": um_key,
}
column.pop("본뜬열", None)
if origin:
column["본뜬"] = origin
tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS
for file, kind in PART_KINDS if copy else ():
got = read_json(copy / file)
if not isinstance(got, dict):
continue
number = store._KEY[kind]
have = store.read(kind, new_id)["문서"]
body = {k: v for k, v in got.items() if k != "계산값"}
body = {**body, "열": new_id, number: have.get(number)}
if kind in store.SHEETS:
body = store._server_values(body)
with store._LOCK:
atomic_write_json(store._path(kind, new_id), body)
links = read_json(copy / LINK_FILE) if copy else None
if isinstance(links, dict) and links.get("줄"):
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
def personal_item(company: int, user: int, made: dict[str, Any]) -> dict[str, Any]:
"""새로 만든 개인 층 항목의 `summary_item`."""
return {
"layer": "personal",
"owner": f"{company}/{user}",
"key": made["key"],
"name": made["이름"],
}
def personal_folder(company: int, user: int) -> Path:
return layers.personal_dir(company, user)
+152 -63
View File
@@ -1,20 +1,24 @@
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3 · 25-2b).
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 ·
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
- 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사
(`M02_Item_Source.build_source` — 집계 · 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 ·
단가) + 프로젝트 값 `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` ·
`manifest.json`
- 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로([새로 가져오기]는 사용자가)
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) — 단 프로젝트에서 고친
흔적(`manifest.고침`)이 있으면 남김
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가 · 단가는 M01 시스템 것.
- 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값
`입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json`
- 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로
([재적용]은 사용자가)
- 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 —
프로젝트에서 고친 흔적(`manifest.고침`)이 있으면 지우지 않고 `_지난/` 에 옮겨 둠
- 옛 항목 폴더(`templates/items/{층}_{주인}_{키}/` · 25-2b)는 그 항목을 쓰는 구조물마다 복사해
옮겨 받음(고친 값 유지)
"""
from __future__ import annotations
import logging
import re
import shutil
from datetime import datetime
from pathlib import Path
@@ -29,118 +33,197 @@ from M02_MasterTemplete.M02_Item_Source import (
LINK_FILE,
PRICE_FILE,
build_source,
item_path,
read_json,
version_of,
)
logger = logging.getLogger(__name__)
ITEMS_DIRNAME = "items"
STRUCTURES_DIRNAME = "structures"
OLD_ITEMS_DIRNAME = "items" # 25-2b 항목 폴더 — 옮겨 받기만
PAST_DIRNAME = "_지난" # 고친 흔적이 있는 복사본을 지우지 않고 옮겨 두는 자리
MANIFEST_NAME = "manifest.json"
VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}}
PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}}
LOCAL_FILES = (VALUES_FILE, PICKS_FILE)
#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점`
FOLDER_PATTERN = re.compile(r"^(?:pipe@\d+(?:\.\d+)?|[0-9A-Za-z][0-9A-Za-z_-]*)$")
def structures_dir(project_root: str | Path) -> Path:
return layers.project_dir(project_root) / STRUCTURES_DIRNAME
def items_dir(project_root: str | Path) -> Path:
return layers.project_dir(project_root) / ITEMS_DIRNAME
"""옛 항목 폴더 뿌리(25-2b) — 옮겨 받기에만 씀."""
return layers.project_dir(project_root) / OLD_ITEMS_DIRNAME
def folder_name(owner_id: str) -> str:
"""구조물 id → 복사본 폴더 이름 — id 없는 옛 구조물(`#차례`)은 `n차례`."""
return owner_id.replace("#", "n")
def now_text() -> str:
return datetime.now().isoformat(timespec="seconds")
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
origin = manifest.get("원본") if isinstance(manifest, dict) else None
if not isinstance(origin, dict):
return False
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
"""manifest `원본` — 층 · 주인 · 키 · 이름 + 경로(26-1 · 가져온 때 층 SS 줄)."""
try:
path = item_path(item)
except LookupError:
path = []
return {**item, "경로": path}
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
"""원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}}
def write_snapshot(target: Path, item: dict[str, Any], users: list[str], extra: dict) -> dict:
def source_files(files: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in files.items() if k not in LOCAL_FILES}
def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dict) -> dict:
"""폴더에 새로 씀 — manifest 는 마지막(있으면 다 쓴 폴더 · 끊기면 다음 저장이 다시 씀)."""
files = build_snapshot(item)
target.mkdir(parents=True, exist_ok=True)
for name, content in files.items():
atomic_write_json(target / name, content)
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
stamp = now_text()
source = version_of(source_files(files))
manifest = {
"원본": item,
"판": version_of(source),
"원본판": version_of(source),
"원본": origin_of(item),
"판": source,
"원본판": source,
"적용일": stamp,
"가져온날": stamp,
"구조물": users,
"구조물": [owner_id],
**extra,
}
atomic_write_json(target / MANIFEST_NAME, manifest)
return manifest
def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]:
"""`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`)."""
found: dict[str, tuple[dict[str, Any], list[str]]] = {}
def add(item: dict[str, Any] | None, owner_id: str) -> None:
if item:
found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id)
def assignments(project_root: str | Path) -> dict[str, dict[str, Any]]:
"""`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만."""
found: dict[str, dict[str, Any]] = {}
_, structures = load_structures(str(project_root))
for index, structure in enumerate(structures):
add(structure.summary_item, structure.structure_id or f"#{index}")
if structure.summary_item:
found[structure.structure_id or f"#{index}"] = structure.summary_item
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}")
if point.summary_item:
found[f"pipe@{round(float(point.chainage_m), 3)}"] = point.summary_item
return found
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
"""지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름."""
base = items_dir(project_root)
def retire(folder: Path) -> str | None:
"""지울 복사본 — 고친 흔적이 있으면 `_지난/{이름}_{때}` 로 옮기고 그 이름 · 아니면 지움."""
manifest = read_json(folder / MANIFEST_NAME)
if isinstance(manifest, dict) and manifest.get("고침"):
past = folder.parent / PAST_DIRNAME
past.mkdir(parents=True, exist_ok=True)
target = past / f"{folder.name}_{datetime.now():%Y%m%d%H%M%S}"
shutil.move(str(folder), str(target))
return target.name
shutil.rmtree(folder)
return None
def migrate_items(project_root: str | Path) -> list[str]:
"""옛 항목 폴더 → 그 항목을 쓰는 구조물마다 복사(고친 값 유지) — 옮겨 받은 구조물 폴더 이름.
다 옮긴 옛 폴더는 지움 · 쓰는 구조물이 없고 고친 흔적이 있으면 `structures/_지난/` 에 둠."""
old = items_dir(project_root)
if not old.is_dir():
return []
base = structures_dir(project_root)
wanted = assignments(project_root)
moved: list[str] = []
for child in sorted(p for p in old.iterdir() if p.is_dir()):
manifest = read_json(child / MANIFEST_NAME)
if not isinstance(manifest, dict):
continue
users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name]
for owner in users:
target = base / folder_name(owner)
if (target / MANIFEST_NAME).is_file():
continue # 이미 새 복사본이 있음
shutil.copytree(child, target, dirs_exist_ok=True)
origin = manifest.get("원본") or {}
atomic_write_json(
target / MANIFEST_NAME,
{**manifest, "원본": origin_of(origin), "구조물": [owner], "옮겨받음": now_text()},
)
moved.append(target.name)
if users or not manifest.get("고침"):
shutil.rmtree(child)
else:
(base / PAST_DIRNAME).mkdir(parents=True, exist_ok=True)
shutil.move(str(child), str(base / PAST_DIRNAME / f"items_{child.name}"))
if not any(old.iterdir()):
old.rmdir()
return moved
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
"""구조물 복사본을 지정에 맞춤 — `{만듦, 지움, 못만듦, 옮겨받음}` 폴더 이름."""
moved = migrate_items(project_root)
base = structures_dir(project_root)
wanted = {
folder_name(owner): (owner, item) for owner, item in assignments(project_root).items()
}
made: list[str] = []
failed: list[str] = []
for folder, (item, users) in sorted(wanted.items()):
removed: list[str] = []
for folder, (owner, item) in sorted(wanted.items()):
target = base / folder
manifest = read_json(target / MANIFEST_NAME)
if isinstance(manifest, dict):
if manifest.get("구조물") != users:
atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users})
if isinstance(manifest, dict) and same_item(manifest, item):
continue
if target.is_dir(): # 다른 항목으로 바뀜 — 옛 것은 지우거나(고침이면) `_지난` 에
retire(target)
removed.append(folder)
try:
write_snapshot(target, item, users, {})
write_snapshot(target, item, owner, {})
except (LookupError, ValueError) as error:
logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error)
logger.warning("집계 항목 복사본을 못 만듦 (%s): %s", folder, error)
failed.append(folder)
continue
made.append(folder)
removed: list[str] = []
if base.is_dir():
for child in sorted(base.iterdir()):
if not child.is_dir() or child.name in wanted:
continue
manifest = read_json(child / MANIFEST_NAME)
if isinstance(manifest, dict) and manifest.get("고침"):
if manifest.get("구조물"): # 고친 복사본은 남기되 쓰는 구조물은 비움
atomic_write_json(child / MANIFEST_NAME, {**manifest, "구조물": []})
continue
shutil.rmtree(child)
removed.append(child.name)
return {"만듦": made, "지움": removed, "못만듦": failed}
if child.is_dir() and not child.name.startswith("_") and child.name not in wanted:
retire(child)
removed.append(child.name)
return {"만듦": made, "지움": removed, "못만듦": failed, "옮겨받음": moved}
def safe_sync(project_root: str | Path) -> None:
"""저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤)."""
"""저장 뒤 부름 — 실패해도 저장은 그대로(복사본은 다음 저장이 다시 맞춤)."""
try:
result = sync_snapshots(project_root)
if any(result.values()):
logger.info("집계 항목 스냅샷: %s", result)
logger.info("집계 항목 복사본: %s", result)
except Exception:
logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root)
logger.exception("집계 항목 복사본 맞춤 실패 (저장은 유지): %s", project_root)
def complete_old(folder: Path) -> bool:
"""옛 스냅샷(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날).
"""
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날)."""
manifest = read_json(folder / MANIFEST_NAME)
if not isinstance(manifest, dict):
return False
@@ -151,15 +234,19 @@ def complete_old(folder: Path) -> bool:
]
if not lacking and "원본판" in manifest:
return False
source = (
build_source(manifest["원본"]) if (LINK_FILE in lacking or PRICE_FILE in lacking) else {}
)
need_source = LINK_FILE in lacking or PRICE_FILE in lacking
try:
source = build_source(manifest["원본"]) if need_source else {}
except LookupError:
source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림)
for name in lacking:
atomic_write_json(folder / name, source.get(name, {}))
atomic_write_json(
folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {})
)
kept = {
name: read_json(folder / name)
for name in sorted(p.name for p in folder.glob("*.json"))
if name not in (MANIFEST_NAME, VALUES_FILE, PICKS_FILE)
if name not in (MANIFEST_NAME, *LOCAL_FILES)
}
atomic_write_json(
folder / MANIFEST_NAME,
@@ -167,13 +254,15 @@ def complete_old(folder: Path) -> bool:
**manifest,
"가져온날": manifest.get("가져온날") or manifest.get("적용일"),
"원본판": manifest.get("원본판") or version_of(kept),
"옮겨받음": now_text(),
"옮겨받음": manifest.get("옮겨받음") or now_text(),
},
)
return True
def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None:
"""프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None."""
data = read_json(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None:
"""그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음)."""
if items_dir(project_root).is_dir():
migrate_items(project_root)
data = read_json(structures_dir(project_root) / folder_name(owner_id) / "집계.json")
return data if isinstance(data, dict) else None
+17 -5
View File
@@ -49,18 +49,30 @@ def read_json(path: Path) -> Any:
def row_of(document: Any, key: str) -> dict[str, Any] | None:
rows = document.get("줄") if isinstance(document, dict) else None
for row in rows or []:
if isinstance(row, dict) and row.get("키") == key:
# 옛 키로도 — 회사 · 개인 층 SS 키는 새 모양(SC · SP)으로 옮기며 `옛키` 에 남김(PLAN 26-4a)
if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])):
return row
return None
def item_name(item: dict[str, Any]) -> str:
"""층 SS 줄의 보이는 이름(종류 · 분류1~3) — 없으면 LookupError."""
def row_path(row: dict[str, Any]) -> list[str]:
"""SS 줄의 경로(26-1) — `경로` 칸이 있으면 그것 · 옛 줄은 종류 · 분류1~3(빈 분류는 건너뜀)."""
if isinstance(row.get("경로"), list):
return [str(p) for p in row["경로"] if str(p or "").strip()]
return [str(row[k]) for k in ("종류", "분류1", "분류2", "분류3") if row.get(k)]
def item_path(item: dict[str, Any]) -> list[str]:
"""층 SS 줄의 경로 — 없으면 LookupError."""
row = row_of(read_json(layer_root(item) / layers.COMBO_FILE), str(item.get("key")))
if row is None:
raise LookupError(f"집계 항목 {item.get('key')} 이 {item.get('layer')} 층에 없음")
parts = (row.get(k) for k in ("종류", "분류1", "분류2", "분류3"))
return " ".join(str(p) for p in parts if p) or str(row["키"])
return row_path(row) or [str(row["키"])]
def item_name(item: dict[str, Any]) -> str:
"""층 SS 줄의 보이는 이름(경로를 이은 글) — 없으면 LookupError."""
return " ".join(item_path(item))
def version_of(content: Any) -> str:
@@ -403,3 +403,39 @@ export const saveInputLinks = (
줄: InputLine[],
): Promise<{ 판: string }> =>
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
export interface TreeItem {
key: string;
열: string;
경로: string[];
이름: string;
단위?: string | null;
일위대가?: string | null;
}
export interface TreeNote {
op: string;
경로?: string[];
열?: string;
지운항목?: string[];
}
export interface TreeView {
판: string;
폴더: string[][];
items: TreeItem[];
알림?: TreeNote;
}
export interface TreeEdit {
op: "add-folder" | "add-item" | "rename" | "move" | "delete";
경로?: string[];
이름?: string;
새경로?: string[];
키?: string;
단위?: string;
}
export const fetchTree = (layer: Layer): Promise<TreeView> => call(`/layers/${layer}/tree`);
/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */
export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise<TreeView> =>
call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
@@ -0,0 +1,147 @@
"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a · 26-4a).
제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 ·
키(층마다 머리가 다름 · 폴더 · 종류 뜻 없는 일련번호 6자리) —
마스터 `SS000001` · 회사 `SC{회사번호}-000001` · 개인 `SP{사용자번호}-000001`
한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) · 층 사이 복사 ·
개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은
옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에
남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`).
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
저장소는 `store.sync_combos` · `store.read_combos` · `store.find_combo` 이름으로 이 파일을 부름.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
HEAD = "SS" # 마스터(과 회사 · 개인 · 프로젝트 층 밖 모든 층)
_OLD = re.compile(r"^SS\d{6}$")
NOTE = (
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
"키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 "
"지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열"
)
def key_head(root: Path) -> str:
"""층 폴더 → 키 머리 — 회사 `SC{회사}-` · 개인 `SP{사용자}-` ·
그 밖(마스터 · 프로젝트 사본) `SS`."""
try:
parts = Path(root).resolve().relative_to(layers.storage_root()).parts
except ValueError:
return HEAD
if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME:
return f"SC{parts[0]}-"
if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME:
return f"SP{parts[1]}-"
return HEAD
def _number(head: str, key: str) -> int | None:
"""그 머리의 키면 여섯 자리 수 · 아니면 None."""
found = re.fullmatch(re.escape(head) + r"(\d{6})", str(key))
return int(found.group(1)) if found else None
def find(rows: list[dict[str, Any]], key: str) -> dict[str, Any] | None:
"""키로 줄 — 지금 키 또는 옮기기 전 옛 키(프로젝트 복사본 · 구조물 기록이 들고 있는 것)."""
key = str(key)
for row in rows:
if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])):
return row
return None
def _row(
key: str, col: dict[str, Any], numbers: dict[str, str], olds: list[str] | None = None
) -> dict[str, Any]:
path = tree.path_of(col)
extra = {"옛키": olds} if olds else {}
return {
"키": key,
"열": col["id"],
"경로": path,
"이름": tree.name_of(path),
**tree.old_fields(path),
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),
**extra,
}
def sync() -> dict[str, Any]:
"""장부를 집계표에 맞춤 — 새 열 = 그 층 새 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
옛 모양 키(회사 · 개인 층의 SS)는 새 모양으로(옛 키는 `옛키`) · 줄 차례 = 집계표 열 차례.
바뀐 것이 없으면 파일을 안 씀."""
path = store.root() / store.COMBO_FILE
head = key_head(store.root())
with store._LOCK:
old = json.loads(path.read_bytes()) if path.is_file() else {}
before = {row["열"]: row for row in old.get("줄", []) if isinstance(row, dict)}
taken = {n for row in before.values() if (n := _number(head, row.get("키"))) is not None}
after = max(old.get("다음", 1), max(taken, default=0) + 1)
numbers = store.structure_numbers()
rows = []
for col in store._structure_columns():
was = before.get(col["id"]) or {}
key, olds = was.get("키"), list(was.get("옛키") or [])
if key is not None and _number(head, key) is None: # 옛 모양 — 숫자 여섯 그대로 옮김
olds.append(key)
digits = int(key[2:]) if _OLD.match(str(key)) else None
key = f"{head}{digits:06d}" if digits and digits not in taken else None
if key:
taken.add(digits)
if key is None:
key = f"{head}{after:06d}"
taken.add(after)
after += 1
rows.append(_row(key, col, numbers.get(col["id"], {}), olds))
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after}
if old.get("지운번호"):
doc["지운번호"] = old["지운번호"] # 지운 열의 번호 — 다시 안 줌
doc["줄"] = rows
if doc != old:
atomic_write_json(path, doc)
return doc
def read() -> dict[str, Any]:
"""장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · 회사 · 개인 층의 옛 SS 키는 새
모양으로 옮김 · `경로` 가 없던 옛 줄은 읽을 때 종류 · 분류1~3 에서 채움(빈 분류 건너뜀)."""
path = store.root() / store.COMBO_FILE
doc = json.loads(path.read_bytes()) if path.is_file() else sync()
head = key_head(store.root())
if head != HEAD and any(_number(head, r.get("키")) is None for r in doc.get("줄") or []):
doc = sync() # 회사 · 개인 층의 옛 SS 키 — 처음 읽을 때 한 번 새 모양으로(옛키 남김)
for row in doc.get("줄") or []:
if "경로" not in row:
row["경로"] = tree.row_path(row)
row["이름"] = tree.name_of(row["경로"])
return doc
def migrate_paths() -> bool:
"""옛 집계표 · 장부 → 경로 모양(열 `경로` · 머리 줄 수 · 장부 `경로` · `이름`) — 한 번 · 두 번
돌려도 같음. 바뀐 것이 없으면 파일을 안 씀 · 바꿨으면 참. 지금 층(`store.at`) 기준."""
got = store.read("table", store.STRUCTURE_TABLE)
if tree.normalize_table(got["문서"]) != got["문서"]:
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
return True
path = store.root() / store.COMBO_FILE
before = path.read_bytes() if path.is_file() else b""
sync()
return (path.read_bytes() if path.is_file() else b"") != before
@@ -15,6 +15,7 @@ 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_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
_OWNER = "현장"
@@ -150,7 +151,7 @@ def branch_material_combo(
return matset.create(row)["키"]
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
default = " ".join(h for h in store._heads(heads) if h) if heads else column
default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1)
name = name.strip() or str(was.get("이름") or default or column)
rows = matset.listing()["줄"]
return _branch(
@@ -38,7 +38,7 @@ const TEXT = {
empty: "맞는 조합 없음",
none: "조합 없음",
use: "이 열에 쓰기",
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로",
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로",
useOk: "바꿈",
used: "이 열이 {value} 을 씀",
loadFailed: "조합 목록을 못 받음 — {value}",
@@ -45,7 +45,7 @@ def pill_key(logic: str, name: str) -> str:
def _item(key: str) -> dict[str, Any]:
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
return row
@@ -48,9 +48,9 @@ const TEXT = {
count: "로직 {logics} · 입력 {inputs}",
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
auto: "자동값",
noItem: "집계표를 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴",
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
noLogic: "이 일위대가에 담긴 로직이 없음",
missing: "빠진 로직 {value}",
failed: "입력 목록을 못 받음 — {value}",
@@ -22,6 +22,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
ITEM_FIELDS = (
"열",
"경로",
"이름",
"종류",
"분류1",
"분류2",
@@ -74,8 +76,9 @@ def _combo_names() -> dict[str, str]:
def items(folder: Path) -> list[dict[str, Any]]:
"""그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · `names` = 화면 이름(키 대신 · 없으면 null) —
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
도번 · 면적번 · 재료번 = 항목(열) 이름."""
ua = _combo_names()
@@ -84,7 +87,7 @@ def items(folder: Path) -> list[dict[str, Any]]:
um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]}
out = []
for r in rows:
head = " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k))
head = r.get("이름")
names = {
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
"자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None,
@@ -109,7 +112,7 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
새 번호 · 새 SS 키 ·
열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`."""
with store.at(source):
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
col_id = row["열"]
@@ -155,4 +158,4 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
atomic_write_json(store._path(kind, new_id), body)
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)}
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
@@ -13,6 +13,7 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`)
FILE = mmc.FILE
@@ -47,7 +48,7 @@ def listing() -> dict[str, Any]:
def _name(col: dict) -> str:
return " ".join(h for h in store._heads(col) if h) or col["id"]
return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1)
def one(key: str) -> dict[str, Any]:
@@ -169,7 +169,7 @@ def get_template(kind: str, name: str) -> dict:
@router.put("/templates/{kind}/{name}")
def put_template(kind: str, name: str, body: SaveBody) -> dict:
return _call(store.write, kind, name, body.판, body.문서)
return _call(store.write, kind, name, body.판, body.문서, True) # 화면 [저장](26-7e)
@router.delete("/templates/{kind}/{name}")
@@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router import (
BranchBody,
@@ -44,6 +45,18 @@ class InputLinksBody(BaseModel):
줄: list[Any] = []
class TreeBody(BaseModel):
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
op: str
경로: list[str] | None = None
이름: str | None = None
새경로: list[str] | None = None
키: str | None = None
단위: str | None = None
판: str
class CopyItemBody(BaseModel):
from_layer: Literal["system", "company"]
from_owner: str | None = None
@@ -195,6 +208,53 @@ def get_item_pills(
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
def _tree_view(folder: Path) -> dict[str, Any]:
"""나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디."""
with store.at(folder):
got = store.read("table", store.STRUCTURE_TABLE)
doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음
return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)}
@router.get("/layers/{layer}/tree")
def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
folder = _root(session, layer, owner)
return _call(folder, _tree_view, folder)
def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
"""집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결."""
got = store.read("table", store.STRUCTURE_TABLE)
if got["판"] != body.get("판"):
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]})
ids = {} # 키 · 옛 키(회사 · 개인 층 SS) → 열 id
for r in store.read_combos().get("줄", []):
for key in [r.get("키"), *(r.get("옛키") or [])]:
ids[str(key)] = str(r.get("열"))
try:
doc, note = tree.apply(got["문서"], body, ids)
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
return {**_tree_view(folder), "알림": note}
@router.post("/layers/{layer}/tree")
def post_tree(
layer: Layer,
body: TreeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409.
쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`)."""
folder = _root(session, layer, owner, write=True)
return _call(folder, _edit_tree, folder, body.model_dump())
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
@@ -305,7 +305,7 @@ async def save_layer_template(
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
with store.at(folder):
try:
info = store.write(kind, name, version, doc)
info = store.write(kind, name, version, doc, screen=True) # 화면 [저장](26-7e)
except store.StoreError as error:
raise HTTPException(status_code=error.status, detail=error.detail) from error
return {"종류": kind, "이름": name, "판": info["판"], "층": layer}
+53 -108
View File
@@ -19,6 +19,7 @@ from typing import Any
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
@@ -46,9 +47,13 @@ def at(folder: str | Path) -> Iterator[Path]:
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
# (둘 다 스프레드시트 통합문서)
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3]
# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 ·
# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장)
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는
# 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양:
# 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌)
# 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 ·
# 12장 옛 6자리(`SD010101`)와 글자 모양이 달라 겹치지 않음 · 층마다 따로 셈
# (파일은 열 id 로 가름 · 번호는 보이는 이름)
# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`)
STRUCTURE_TABLE = "구조물집계표"
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
IN_USE = {
@@ -65,8 +70,7 @@ PARTS = (
)
_KEY = {kind: key for kind, key, _, _ in PARTS}
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_LEVELS = 4
_CODE = re.compile(r"^S[DAM](\d{8})$")
_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$")
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
@@ -146,7 +150,7 @@ def _check_skeleton(kind: str, doc: Any) -> None:
raise StoreError(400, str(e)) from e
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]:
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
@@ -154,16 +158,22 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로)."""
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
path = _path(kind, name)
_check_skeleton(kind, doc)
origins = _take_origins(doc) if kind == "table" else {}
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
if kind in SHEETS:
doc = _server_values(doc)
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise StoreError(409, {"stale": [name], "판": have})
if screen and have and kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes())))
if have and kind in _KEY:
number = json.loads(path.read_bytes()).get(_KEY[kind])
doc = {**doc, _KEY[kind]: number} if number else doc
@@ -201,7 +211,8 @@ def _docs(kind: str) -> list[tuple[str, Any]]:
def _code(doc: Any, key: str) -> str | None:
"""문서의 번호 8자리 — 12장 6자리는 끝에 00 · 옛 `구-01` · `표-01` · 없음은 None."""
"""문서 번호의 뒷자리 — 옛 8자리 · 새 `-` + 6자리 · 12장 6자리는 끝에 00 ·
옛 `구-01` · `표-01` · 없음은 None."""
text = str(doc.get(key) or "") if isinstance(doc, dict) else ""
if found := _CODE.match(text):
return found.group(1)
@@ -210,7 +221,7 @@ def _code(doc: Any, key: str) -> str | None:
def _codes() -> dict[str, str]:
"""`{열 id: 8자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
codes: dict[str, str] = {}
for kind, key, _, _ in reversed(PARTS):
codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))})
@@ -228,9 +239,8 @@ def structure_numbers() -> dict[str, dict[str, str]]:
def _heads(col: dict[str, Any]) -> tuple[str | None, ...]:
"""제목행 네 층(종류 · 분류1 · 분류2 · 분류3) — 빈 층은 None."""
parts = [*(col.get("머리") or []), *[None] * _LEVELS][:_LEVELS]
return tuple((str(p).strip() if p is not None else "") or None for p in parts)
"""옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음)."""
return tuple(tree.old_fields(tree.path_of(col)).values())
def _structure_columns() -> list[dict[str, Any]]:
@@ -247,53 +257,24 @@ def _column_ids() -> list[str]:
return [col["id"] for col in _structure_columns()]
def _pick_code(
col: dict[str, Any],
taken: dict[str, str],
heads: dict[str, tuple],
retired: list[str] | None = None,
) -> str:
"""새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를
이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음
번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로.
`taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 ·
`retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦)."""
parts = _heads(col)
codes = [*taken.values(), *(retired or [])]
mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads]
_SERIAL = re.compile(r"^-(\d{6})$")
def next_of(prefix: str) -> str:
at = slice(len(prefix), len(prefix) + 2)
found = [int(c[at]) for c in codes if c.startswith(prefix)]
return f"{max(found, default=0) + 1:02d}"
code = ""
for depth, head in enumerate(parts):
if depth and head is None:
code += "00"
continue
at = slice(2 * depth, 2 * depth + 2)
group = parts[: depth + 1]
same = (c[at] for h, c in mates if h[: depth + 1] == group and c.startswith(code))
leaf = depth == _LEVELS - 1
code += (None if leaf else next(same, None)) or next_of(code)
if code in codes:
code = code[:-2] + next_of(code[:-2])
return code
def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str:
"""새 항목 번호 뒷자리 — 붙임표 + 일련 6자리(새 모양 번호 · 지운 번호 가운데 가장 큰 수 + 1) ·
옛 8자리는 셈에 안 넣음(모양이 달라 겹칠 일 없음)."""
found = (_SERIAL.match(str(c)) for c in [*taken.values(), *(retired or [])])
return f"-{max((int(m.group(1)) for m in found if m), default=0) + 1:06d}"
def _new_code(column: str) -> str:
"""이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
"""이 열이 쓸 번호 뒷자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
taken = _codes()
if column in taken:
return taken[column]
cols = _structure_columns()
col = next((c for c in cols if c["id"] == column), {"id": column})
return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}, _retired())
return taken[column] if column in taken else _serial(taken, _retired())
def _retired() -> list[str]:
"""지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
"""지운 열의 번호 뒷자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
path = root() / COMBO_FILE
return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else []
@@ -321,7 +302,7 @@ def _blank(kind: str, column: str) -> dict[str, Any]:
def _ensure(kind: str, column: str, source: str | None = None) -> bool:
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로.
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 뒷자리)와 같이 만듦 · 있으면 그대로.
`source` 열 파일이 있으면 빈 문서 대신 그 내용(알약 `연결` 포함 · 계산값은 서버가 다시 풂)."""
path = _path(kind, column)
if path.is_file():
@@ -361,8 +342,8 @@ def _take_origins(doc: Any) -> dict[str, str]:
def create_structure(column: str) -> dict[str, Any]:
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 8자리 · 이미 있으면 409.
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 8자리)."""
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리)."""
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
if not _ensure("structure", column):
@@ -447,7 +428,7 @@ def _move_basis() -> list[str]:
def renumber() -> dict[str, list[str]]:
"""한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로
(6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) ·
(6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) ·
구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
moved = _move_basis()
gone = prune_removed_structures()
@@ -455,12 +436,11 @@ def renumber() -> dict[str, list[str]]:
made: list[str] = []
with _LOCK:
cols = _structure_columns()
heads = {c["id"]: _heads(c) for c in cols}
taken = _codes()
retired = _retired()
for col in cols:
if col["id"] not in taken:
taken[col["id"]] = _pick_code(col, taken, heads, retired)
taken[col["id"]] = _serial(taken, retired)
docs = {kind: dict(_docs(kind)) for kind in _KEY}
for col in cols:
cid = col["id"]
@@ -485,7 +465,7 @@ def _server_values(doc: Any) -> dict[str, Any]:
def backfill_missing_structures() -> list[str]:
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호).
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호).
그 사이 남이 만들었으면 그대로 둠."""
made: list[str] = []
for cid in _column_ids():
@@ -493,61 +473,26 @@ def backfill_missing_structures() -> list[str]:
return made
# ── 구조물집계표조합 ─────────────────────
# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) ·
# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억)
# ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ─────────────────────
COMBO_FILE = layers.COMBO_FILE
_COMBO_HEAD = "SS"
_COMBO_NOTE = (
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀"
)
def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
kind, level1, level2, level3 = _heads(col)
return {
"키": key,
"열": col["id"],
"종류": kind,
"분류1": level1,
"분류2": level2,
"분류3": level3,
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),
}
def sync_combos() -> dict[str, Any]:
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
path = root() / COMBO_FILE
with _LOCK:
old = json.loads(path.read_bytes()) if path.is_file() else {}
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()]
after = max(old.get("다음", 1), max(taken, default=0) + 1)
numbers = structure_numbers()
rows = []
for col in _structure_columns():
if col["id"] not in keys:
keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}"
after += 1
rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {})))
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after}
if old.get("지운번호"):
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
doc["줄"] = rows
if doc != old:
atomic_write_json(path, doc)
return doc
"""조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름)."""
from M02_MasterTemplete import M02_MasterTemplete_Book as book
return book.sync()
def find_combo(key: str) -> dict[str, Any] | None:
"""키(또는 옮기기 전 옛 키)로 장부 줄 — 없으면 None(`Book.find`)."""
from M02_MasterTemplete import M02_MasterTemplete_Book as book
return book.find(book.read().get("줄", []), key)
def read_combos() -> dict[str, Any]:
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
path = root() / COMBO_FILE
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
"""조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`)."""
from M02_MasterTemplete import M02_MasterTemplete_Book as book
return book.read()
@@ -57,12 +57,12 @@ const TEXT = {
none: "고를 것이 없음",
pickPrice: "일위대가 고르기",
combosFailed: "일위대가 목록을 못 받음 — {value}",
saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김",
saveFirst: "새 열은 구조물 조합을 먼저 저장해야 번호가 생김",
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
pickCopy: "{value} 복사해 가져오기",
copied: "복사해 가져옴",
copyFailed: "복사 실패 — {value}",
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물집계표 일위대가 줄에서 고름",
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름",
noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음",
inputsFailed: "일위대가 입력값을 못 받음 — {value}",
};
@@ -180,6 +180,30 @@ export interface StructureColumn {
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */
export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수";
/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b) — 비고 열 · 전구간 줄(본문 줄) · 합계 줄.
* 양식 파일은 그대로 — 보일 문서에서만 빼고 [저장] 때 저장본 것을 다시 끼움(`comboMerge`). */
const HIDDEN_COLUMNS = ["memo"];
type ComboDoc = { 열: StructureColumn[]; 줄?: unknown[]; 합계줄?: unknown[] };
export function comboView<T>(doc: T): T {
const d = doc as unknown as ComboDoc;
const 열 = (d.열 ?? []).filter((c) => !HIDDEN_COLUMNS.includes(c.id));
return { ...d, 열, 줄: [], 합계줄: [] } as unknown as T;
}
/** 보인 표 고침 + 저장본의 숨긴 것 — 열 차례는 저장본(표에서 열을 더하거나 지우지 않음 · 26-7d) */
export function comboMerge<T>(stored: unknown, edited: T): T {
const s = stored as ComboDoc;
const e = edited as unknown as ComboDoc;
const byId = new Map(e.열.map((c) => [c.id, c]));
const kept = (s.열 ?? []).flatMap((c) =>
HIDDEN_COLUMNS.includes(c.id) ? [c] : byId.has(c.id) ? [byId.get(c.id)!] : [],
);
const known = new Set(kept.map((c) => c.id));
const 열 = [...kept, ...e.열.filter((c) => !known.has(c.id))];
return { ...e, 열, 줄: s.줄 ?? [], 합계줄: s.합계줄 ?? [] } as unknown as T;
}
/** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */
export const columnHead = (col: StructureColumn): string =>
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
@@ -0,0 +1,317 @@
"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침.
항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름)
· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀)
이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 —
옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음.
나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐
돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기
(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수).
"""
from __future__ import annotations
import copy
import re
from typing import Any
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
_OLD_NAMES = ("종류", "분류1", "분류2", "분류3")
OPS = ("add-folder", "add-item", "rename", "move", "delete")
MAX_NAME = 60
_BAD = re.compile(r"[\x00-\x1f]")
class TreeError(Exception):
def __init__(self, status: int, detail: Any):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 경로 읽기 ─────────────────────────────────────────
def _clean(parts: Any) -> list[str]:
if not isinstance(parts, list):
return []
return [str(p).strip() for p in parts if p is not None and str(p).strip()]
def path_of(col: dict[str, Any]) -> list[str]:
"""열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`."""
heads = col.get("머리")
return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로"))
def row_path(row: dict[str, Any]) -> list[str]:
"""SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀)."""
got = _clean(row.get("경로"))
return got or _clean([row.get(k) for k in _OLD_NAMES])
def name_of(path: list[str]) -> str:
return " ".join(path)
def old_fields(path: list[str]) -> dict[str, str | None]:
"""옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음)."""
padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS]
return dict(zip(_OLD_NAMES, padded))
def is_item(col: Any) -> bool:
return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수"
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [c for c in doc.get("열") or [] if is_item(c)]
# ── 집계표 맞춤 ───────────────────────────────────────
def _trimmed(heads: Any) -> list[Any]:
out = list(heads) if isinstance(heads, list) else []
while out and (out[-1] is None or not str(out[-1]).strip()):
out.pop()
return out
def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
"""저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼
채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) ·
`폴더` = 경로 목록(겹침 · 빈 것 뺌).
글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤."""
out = copy.deepcopy(doc)
cols = [c for c in out.get("열") or [] if isinstance(c, dict)]
depth = max(
[MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))]
+ [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)]
)
names = [str(n) for n in (out.get("층") or [])][:depth]
names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)]
out["층"] = names
for col in cols:
if is_item(col):
path = path_of(col)
col["경로"] = path
col["머리"] = [*path, *[None] * depth][:depth]
else:
col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth]
folders: list[list[str]] = []
for raw in out.get("폴더") or []:
path = _clean(raw)
if path and path not in folders:
folders.append(path)
out["폴더"] = folders
return out
def keep_paths(doc: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
"""화면 표 [저장](PLAN 26-7e) — 정본은 나무: 있던 항목의 경로(머리) · 층 이름 · 빈 폴더는
저장본 그대로 · 브라우저가 보낸 머리는 안 받음. 머리는 저장 맞춤(`normalize_table`)이 경로에서
다시 만듦."""
out = copy.deepcopy(doc)
was = {c["id"]: path_of(c) for c in _items(stored)}
for col in _items(out):
if col["id"] in was:
_set_path(col, was[col["id"]])
out["층"] = copy.deepcopy(stored.get("층") or [])
out["폴더"] = copy.deepcopy(stored.get("폴더") or [])
return out
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
out: list[list[str]] = []
for col in _items(doc):
path = path_of(col)
for n in range(1, len(path)):
if path[:n] not in out:
out.append(path[:n])
for path in doc.get("폴더") or []:
path = _clean(path)
if path and path not in out:
out.append(path)
return out
# ── 나무 고침 ─────────────────────────────────────────
def _name(value: Any, what: str = "이름") -> str:
text = str(value).strip() if isinstance(value, str) else ""
if not text or len(text) > MAX_NAME or _BAD.search(text):
raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀")
return text
def _path(value: Any, what: str = "경로") -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님")
return [_name(v, what) for v in value]
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
"""항목 키 · 옛 키(또는 열 id)로 항목 열 — 없으면 404."""
cid = ids.get(str(key), str(key)) if key else None
col = next((c for c in _items(doc) if c["id"] == cid), None)
if col is None:
raise TreeError(404, f"없는 항목 「{key}」")
return col
def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]:
return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)]
def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
if path in _item_paths(doc, skip):
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
def _set_path(col: dict[str, Any], path: list[str]) -> None:
col["경로"] = path
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
def _common(a: list[str], b: list[str]) -> int:
n = 0
while n < min(len(a), len(b)) and a[n] == b[n]:
n += 1
return n
def _place(doc: dict[str, Any], col: dict[str, Any]) -> None:
"""열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게.
항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝."""
cols = doc.setdefault("열", [])
path = path_of(col)
best, at = -1, None
for i, other in enumerate(cols):
if is_item(other):
share = _common(path, path_of(other))
if share >= best:
best, at = share, i
if at is None:
at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1
cols.insert(at + 1, col)
def _fresh_id(doc: dict[str, Any]) -> str:
used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)}
n = 1
while f"item{n}" in used:
n += 1
return f"item{n}"
def _under(path: list[str], folder: list[str]) -> bool:
return path[: len(folder)] == folder
def _folder(doc: dict[str, Any], where: list[str], what: str) -> None:
"""고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404."""
if not where:
raise TreeError(422, f"{what} 폴더 「경로」 가 없음")
if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)):
raise TreeError(404, f"없는 폴더 「{name_of(where)}」")
def apply(
doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
"""나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때).
본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄."""
out = copy.deepcopy(doc)
ids = ids or {}
op = body.get("op")
if op not in OPS:
raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
where = _path(body.get("경로"))
key = body.get("키")
note: dict[str, Any] = {"op": op}
folders = out.setdefault("폴더", [])
if op == "add-folder":
made = [*where, _name(body.get("이름"))]
if made in folders_of(out) or made in _item_paths(out):
raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」")
folders.append(made)
note["경로"] = made
elif op == "add-item":
made = [*where, _name(body.get("이름"))]
_taken_item(out, made)
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
col.update({"일위대가": None, "자재조합": None})
_set_path(col, made)
_place(out, col)
note.update({"경로": made, "열": col["id"]})
elif op == "rename":
new = _name(body.get("이름"))
if key:
col = _find(out, key, ids)
path = path_of(col)
moved = [*path[:-1], new]
_taken_item(out, moved, [col])
_set_path(col, moved)
note["경로"] = moved
else:
_folder(out, where, "바꿀")
moved = [*where[:-1], new]
if moved != where and (
moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out))
):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved)
note["경로"] = moved
elif op == "move":
to = _path(body.get("새경로"), "새경로")
if key:
col = _find(out, key, ids)
moved = [*to, path_of(col)[-1]]
_taken_item(out, moved, [col])
out["열"] = [c for c in out["열"] if c is not col]
_set_path(col, moved)
_place(out, col)
note["경로"] = moved
else:
_folder(out, where, "옮길")
if _under(to, where):
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
moved = [*to, where[-1]]
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved, replace=True)
note["경로"] = moved
else: # delete
if key:
gone = [_find(out, key, ids)]
else:
_folder(out, where, "지울")
gone = [c for c in _items(out) if _under(path_of(c), where)]
out["폴더"] = [f for f in folders if not _under(_clean(f), where)]
out["열"] = [c for c in out["열"] if all(c is not g for g in gone)]
note["지운항목"] = [g["id"] for g in gone]
return out, note
def _move_folder(
doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False
) -> None:
"""폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 ·
옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김."""
moving = [c for c in _items(doc) if _under(path_of(c), old)]
if replace:
doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)]
for col in moving:
_set_path(col, [*new, *path_of(col)[len(old) :]])
if replace:
_place(doc, col)
doc["폴더"] = [
[*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f
for f in doc.get("폴더") or []
]
@@ -0,0 +1,40 @@
/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
.m02-tree {
padding-top: var(--spacing-8);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-tree__body {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 55vh;
min-height: 4rem;
overflow: auto;
outline: none;
}
.m02-tree__body .ui-tree__row {
min-width: 0;
overflow-wrap: anywhere;
}
.m02-tree__hint {
margin: 0;
padding: var(--spacing-8);
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-treedlg {
width: min(24rem, calc(100vw - 2 * var(--spacing-16)));
}
.m02-treedlg .ui-modal__body {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
@@ -0,0 +1,373 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel.ts
* 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기.
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
* ========================================================================== */
import "./M02_MasterTemplete_TreePanel.css";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
createTreeRow,
openTreeMenu,
renderTree,
type MakeRow,
type TreeMenuItem,
type TreeNode,
} from "@ui/ui_template_tree";
import {
StaleError,
editTree,
fetchTree,
type Layer,
type TreeEdit,
type TreeItem,
type TreeNote,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import {
ancestorsOf,
buildFolders,
countItems,
folderIdOf,
folderOf,
isUnder,
itemIdOf,
leafName,
listFolders,
samePath,
type FolderNode,
} from "./M02_MasterTemplete_TreePanel_Model";
export interface TreePanelOptions {
layer: Layer;
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 항목을 누름 — 표에서 그 열을 고름 */
onPick: (item: TreeItem) => void;
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
settle: () => Promise<false | "clean" | "changed">;
/** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */
onChanged: (colId: string | null) => Promise<void>;
}
export interface TreePanelHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(null = 지움) */
select: (colId: string | null) => void;
}
type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem };
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text });
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const layerLabel = L(
opt.layer === "company"
? "M02_PickerCompany"
: opt.layer === "personal"
? "M02_PickerPersonal"
: "M02_PickerSystem",
);
let view: TreeView | null = null;
let text = "";
let activeCol: string | null = null;
let seq = 0;
let busy = false;
const open = new Set<string>([folderIdOf([])]);
const targets = new Map<string, Target>();
const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true });
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
const root = el("div", { className: "m02-tree", children: [search.root, body] });
/* --- 그리기 --- */
const menuOf = (target: Target): TreeMenuItem[] => {
const isTop = target.kind === "folder" && !target.path.length;
return [
...(target.kind === "folder"
? [
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
]
: []),
...(isTop
? []
: [
{ label: L("M02_TreeRename"), run: () => void rename(target) },
{ label: L("M02_TreeMove"), run: () => void move(target) },
{ label: L("M02_Delete"), run: () => void remove(target) },
]),
];
};
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, id: string): TreeNode {
targets.set(id, target);
return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) };
}
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
const id = folderIdOf(folder.path);
const kids = [
...folder.folders.map((one) => folderNode(one, searching)),
...folder.items.map((item) =>
node(
{ kind: "item", item },
{
label: leafName(item),
count: null,
tip: `${item.이름} · ${item.key}`,
run: () => opt.onPick(item),
},
itemIdOf(item),
),
),
];
return node(
{ kind: "folder", path: folder.path },
{
label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel,
count: countItems(folder),
children: kids,
open: searching || open.has(id),
run: () => void (open.has(id) ? open.delete(id) : open.add(id)),
},
id,
);
}
const make: MakeRow = (one, caret, depth, onClick) => {
const button = createTreeRow(one, caret, depth, onClick);
button.dataset.node = one.id;
button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`);
return button;
};
function paint(): void {
const focused = (document.activeElement as HTMLElement | null)?.dataset?.node;
targets.clear();
if (!view) return body.replaceChildren(hint(L("M02_PickerLoading")));
const searching = text.trim() !== "";
const tree = buildFolders(view, text);
const none = countItems(tree) === 0 && (searching || !tree.folders.length);
body.replaceChildren(
...renderTree([folderNode(tree, searching)], make),
...(none
? [
hint(
L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"),
),
]
: []),
);
if (focused) rowOf(focused)?.focus({ preventScroll: true });
}
const rowOf = (id: string): HTMLElement | null =>
[...body.querySelectorAll<HTMLElement>("[data-node]")].find((b) => b.dataset.node === id) ??
null;
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await fetchTree(opt.layer);
if (mine !== seq) return;
view = got;
} catch (error) {
if (mine !== seq) return;
view = null;
body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
return;
}
paint();
}
function select(colId: string | null): void {
activeCol = colId;
const need =
view && colId ? ancestorsOf(view, colId).filter((p) => !open.has(folderIdOf(p))) : [];
if (need.length) {
need.forEach((p) => open.add(folderIdOf(p)));
paint();
} else {
body
.querySelectorAll<HTMLElement>("[data-node]")
.forEach((b) =>
b.classList.toggle("is-active", !!colId && b.dataset.node === `i|${colId}`),
);
}
if (colId) rowOf(`i|${colId}`)?.scrollIntoView({ block: "nearest" });
}
/* --- 고치기 --- */
/** 저장 안 한 표 고침 처리 → (바뀌었으면 나무 다시 읽음) → 참이면 계속 */
async function ready(): Promise<boolean> {
const settled = await opt.settle();
if (!settled) return false;
if (settled === "changed") await refresh();
return true;
}
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
if (!view || busy) return;
busy = true;
try {
const got = await editTree(opt.layer, body_, view.판);
view = got;
const note: TreeNote | undefined = got.알림;
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
const gone = note?.지운항목 ?? [];
const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
activeCol = col;
const parent = note?.경로 ? note.경로.slice(0, -1) : [];
parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1))));
if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") {
if (note.경로) open.add(folderIdOf(note.경로));
}
paint();
showToast(L("M02_TreeDone"), "success");
await opt.onChanged(col);
} catch (error) {
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
if (error instanceof StaleError && error.message === "HTTP 409") {
await refresh();
showToast(L("M02_TreeStale"), "error");
} else {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
} finally {
busy = false;
}
}
const nameOf = (target: Target): string =>
target.kind === "item" ? leafName(target.item) : target.path[target.path.length - 1];
async function addFolder(parent: string[]): Promise<void> {
if (!(await ready())) return;
const got = await askText(L("M02_TreeAddFolder"), [
{ key: "name", label: L("M02_TreeFolderName") },
]);
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
}
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
const got = await askText(L("M02_TreeAddItem"), [
{ key: "name", label: L("M02_TreeItemName") },
{ key: "unit", label: L("M02_TreeUnit"), optional: true },
]);
if (got) {
await edit({
op: "add-item",
경로: parent,
이름: got.name,
...(got.unit ? { 단위: got.unit } : {}),
});
}
}
async function rename(target: Target): Promise<void> {
if (!(await ready())) return;
const got = await askText(L("M02_TreeRename"), [
{
key: "name",
label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"),
value: nameOf(target),
},
]);
if (!got || got.name === nameOf(target)) return;
await edit(
target.kind === "item"
? { op: "rename", 경로: folderOf(target.item), 키: target.item.key, 이름: got.name }
: { op: "rename", 경로: target.path, 이름: got.name },
);
}
async function move(target: Target): Promise<void> {
if (!(await ready()) || !view) return;
const from = target.kind === "item" ? folderOf(target.item) : target.path.slice(0, -1);
const to = await askFolder(
L("M02_TreeMove"),
listFolders(buildFolders(view)),
(path) => target.kind === "folder" && isUnder(path, target.path),
from,
);
if (!to || samePath(to, from)) return;
await edit(
target.kind === "item"
? { op: "move", 경로: from, 키: target.item.key, 새경로: to }
: { op: "move", 경로: target.path, 새경로: to },
);
}
async function remove(target: Target): Promise<void> {
if (!(await ready()) || !view) return;
const folder =
target.kind === "folder" ? findFolder(buildFolders(view), target.path) : undefined;
const message =
target.kind === "item"
? L("M02_TreeDeleteItem").replace("{value}", leafName(target.item))
: L("M02_TreeDeleteFolder")
.replace("{value}", nameOf(target))
.replace("{n}", String(folder ? countItems(folder) : 0));
if (!(await showConfirmDialog(message, L("M02_Delete")))) return;
await edit(
target.kind === "item"
? { op: "delete", 경로: folderOf(target.item), 키: target.item.key }
: { op: "delete", 경로: target.path },
);
}
const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined =>
samePath(from.path, path)
? from
: from.folders.map((one) => findFolder(one, path)).find(Boolean);
/* --- 이음 --- */
search.input.addEventListener("input", () => {
text = search.input.value;
paint();
});
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기)
body.addEventListener("contextmenu", (event) => {
if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return;
event.preventDefault();
openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2));
});
body.addEventListener("keydown", (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
if (!row) return;
const rows = [...body.querySelectorAll<HTMLElement>("[data-node]")].filter(
(b) => !b.closest("[hidden]"),
);
const at = rows.indexOf(row);
const caret = row.querySelector(".ui-tree__caret")?.textContent;
const target = targets.get(row.dataset.node ?? "");
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
rows[at + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
} else if (
(event.key === "ArrowRight" && caret === "▸") ||
(event.key === "ArrowLeft" && caret === "▾")
) {
event.preventDefault();
row.click();
} else if (opt.canEdit && target && event.key === "F2") {
event.preventDefault();
if (!(target.kind === "folder" && !target.path.length)) void rename(target);
} else if (opt.canEdit && target && event.key === "Delete") {
event.preventDefault();
if (!(target.kind === "folder" && !target.path.length)) void remove(target);
}
});
return { root, refresh, select };
}
@@ -0,0 +1,142 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Dialogs.ts
* 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
export interface TextField {
key: string;
label: string;
value?: string;
/** 비워도 됨(항목 단위) */
optional?: boolean;
}
/** 글 칸 하나 이상을 받음 — 확인이면 칸 이름 → 글(앞뒤 공백 뺌) */
export function askText(
title: string,
fields: TextField[],
okLabel = L("M02_TreeOk"),
): Promise<Record<string, string> | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Record<string, string> | null): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const inputs = fields.map((f) => ({
f,
field: createInputField({ label: f.label, value: f.value }),
}));
const ok = createButton({ label: okLabel, variant: "filled" });
const submit = (): void => {
const out: Record<string, string> = {};
for (const { f, field } of inputs) {
out[f.key] = field.input.value.trim();
if (!out[f.key] && !f.optional) return showToast(L("M02_NameNeeded"), "error");
}
finish(out);
close();
};
ok.addEventListener("click", submit);
for (const { field } of inputs) {
field.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) submit();
});
}
body.append(...inputs.map(({ field }) => field.root), ok);
inputs[0]?.field.input.select();
},
});
});
}
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */
export function askFolder(
title: string,
folders: string[][],
blocked: (path: string[]) => boolean,
current: string[],
): Promise<string[] | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: string[] | null): void => {
if (done) return;
done = true;
resolve(value);
};
const choices = [[], ...folders.filter((path) => !blocked(path))];
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const field = createSelectField({
label: L("M02_TreeMoveTo"),
options: choices.map((path, i) => ({
value: String(i),
text: path.length ? path.join(" / ") : L("M02_TreeTop"),
})),
value: String(choices.findIndex((path) => path.join("|") === current.join("|"))),
});
const ok = createButton({ label: L("M02_TreeOk"), variant: "filled" });
ok.addEventListener("click", () => {
finish(choices[Number(field.select.value)] ?? []);
close();
});
body.append(field.root, ok);
},
});
});
}
export type Settle = "save" | "discard" | "cancel";
/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */
export function askSettle(): Promise<Settle> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Settle): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: L("M02_TreeUnsaved"),
closeLabel: L("M02_TreeCancel"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish("cancel"), true),
mount: (body, close) => {
const choose = (value: Settle) => () => {
finish(value);
close();
};
const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" });
const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" });
const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" });
save.addEventListener("click", choose("save"));
discard.addEventListener("click", choose("discard"));
cancel.addEventListener("click", choose("cancel"));
body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] }));
},
});
});
}
@@ -0,0 +1,67 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Model.ts
* 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
* 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유).
* ========================================================================== */
import type { TreeItem, TreeView } from "./M02_MasterTemplete_Api_Fetch";
export interface FolderNode {
/** 이 폴더까지의 경로 — 맨 위 = 빈 배열 */
path: string[];
folders: FolderNode[];
items: TreeItem[];
}
export const samePath = (a: string[], b: string[]): boolean =>
a.length === b.length && a.every((part, i) => part === b[i]);
/** `path` 가 `folder` 와 같거나 그 안인가 */
export const isUnder = (path: string[], folder: string[]): boolean =>
folder.length <= path.length && folder.every((part, i) => part === path[i]);
export const folderIdOf = (path: string[]): string => `f|${path.join("|")}`;
export const itemIdOf = (item: TreeItem): string => `i|${item.열}`;
export const leafName = (item: TreeItem): string => item.경로[item.경로.length - 1] ?? item.이름;
export const folderOf = (item: TreeItem): string[] => item.경로.slice(0, -1);
/** 찾기 — 경로 · 키 · 일위대가 키에 모든 낱말이 들어 있는 항목(빈 글이면 전부) */
export function matches(item: TreeItem, text: string): boolean {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hay = [...item.경로, item.key, item.일위대가 ?? ""].join(" ").toLowerCase();
return words.every((word) => hay.includes(word));
}
/** 폴더 나무 — 찾는 글이 있으면 맞는 항목과 그 위 폴더만 · 없으면 빈 폴더까지 전부 */
export function buildFolders(view: TreeView, text = ""): FolderNode {
const searching = text.trim() !== "";
const root: FolderNode = { path: [], folders: [], items: [] };
const at = (path: string[]): FolderNode =>
path.reduce((node, name, i) => {
let next = node.folders.find((f) => f.path[i] === name);
if (!next) {
next = { path: path.slice(0, i + 1), folders: [], items: [] };
node.folders.push(next);
}
return next;
}, root);
if (!searching) view.폴더.forEach(at);
for (const item of view.items) if (matches(item, text)) at(folderOf(item)).items.push(item);
return root;
}
export const countItems = (node: FolderNode): number =>
node.items.length + node.folders.reduce((sum, f) => sum + countItems(f), 0);
/** 모든 폴더 경로(맨 위 제외) — 옮길 곳 고르기 */
export function listFolders(root: FolderNode): string[][] {
return root.folders.flatMap((f) => [f.path, ...listFolders(f)]);
}
/** 그 열이 들어 있는 폴더 길 — 나무를 그 항목까지 펼칠 때 */
export function ancestorsOf(view: TreeView, colId: string): string[][] {
const item = view.items.find((one) => one.열 === colId);
if (!item) return [];
const folder = folderOf(item);
return folder.map((_, i) => folder.slice(0, i + 1));
}
@@ -17,6 +17,8 @@ import {
type StructureDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import {
comboMerge,
comboView,
createStructurePanel,
pillsFor,
STRUCTURE_TABLE,
@@ -24,6 +26,7 @@ import {
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { inputPills } from "./M02_MasterTemplete_Inputs";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildComboBar } from "./M02_MasterTemplete_Combo";
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
import {
@@ -91,6 +94,10 @@ export interface MainHandle {
confirmLeave: () => Promise<boolean>;
/** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */
openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise<void>;
/** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */
settle: () => Promise<false | "clean" | "changed">;
}
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
@@ -98,9 +105,11 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean {
return layer === "project" || isAdmin;
}
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 ·
* `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */
export interface MainExtras {
top?: HTMLElement;
aside?: HTMLElement;
onColumn?: (column: string | null) => void;
}
@@ -120,7 +129,7 @@ export function createMain(
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너
const root = el("div", {
const column = el("div", {
className: "m02-main",
children: [
...(extras.top ? [extras.top] : []),
@@ -130,6 +139,15 @@ export function createMain(
extra,
],
});
// 왼쪽 나무(주) · 오른쪽 편집 칸 — 나무는 「구조물 조합」일 때만(`showAside`)
const root = extras.aside
? el("div", { className: "m02-shell", children: [extras.aside, column] })
: column;
const isCombo = (at: Selection | null): boolean =>
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
const showAside = (): void => {
if (extras.aside) extras.aside.hidden = !isCombo(sel);
};
let sel: Selection | null = null;
let version = "";
@@ -355,9 +373,11 @@ export function createMain(
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
editor = create(host, doc, {
// 「구조물 조합」 = 나무를 따라오는 참조표(26-7b · d) — 비고 · 전구간 · 합계 숨김 · 열 · 머리는 나무만
editor = create(host, struct ? comboView(doc) : doc, {
mode: at.layer === "project" ? "project" : "master",
readOnly,
lockHead: !!struct,
onChange: () => (dirty = !readOnly),
...(struct
? {
@@ -377,7 +397,11 @@ export function createMain(
}
: {}),
});
if (struct) sheet = editor as SheetEditorHandle;
if (!struct) return;
const shown = editor as SheetEditorHandle;
sheet = shown;
// [저장]은 저장본의 숨긴 것을 다시 끼운 문서(서버가 머리를 경로에서 다시 만듦 · 26-7e)
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
return;
}
if (at.kind === "area" || at.kind === "material") {
@@ -416,11 +440,12 @@ export function createMain(
sel = next;
onOpened?.();
bar();
showAside();
if (!next) {
title.textContent = L("M02_PickTemplate");
return empty("");
}
title.textContent = next.label ?? next.name;
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
try {
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
if (mine !== seq) return;
@@ -496,11 +521,34 @@ export function createMain(
sel = null;
onOpened?.(); // 좌측 양식 · 열 줄 고름 지움
bar();
showAside();
title.textContent = label;
host.replaceChildren(node);
return (d) => (dirty = d);
}
async function openTable(layer: Layer, colId?: string | null, fresh = false): Promise<void> {
const inTable = sel?.kind === "table" && sel.name === STRUCTURE_TABLE && !fresh;
if (!inTable) {
if (!fresh && !(await confirmLeave())) return;
await open({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE });
}
if (colId && sel?.kind === "table") sheet?.selectColumn(colId);
}
async function settle(): Promise<false | "clean" | "changed"> {
if (!dirty) return "clean";
const choice = await askSettle();
if (choice === "cancel") return false;
if (choice === "save") {
await doSave();
return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침
}
dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음
if (sel) await open(sel);
return "changed";
}
save.addEventListener("click", () => void doSave());
back.addEventListener("click", async () => {
const column = sel?.name;
@@ -517,6 +565,7 @@ export function createMain(
if (sel && (await confirmLeave())) void open(sel);
});
bar();
showAside();
empty("");
return { root, open, current: () => sel, confirmLeave, showView };
return { root, open, current: () => sel, confirmLeave, showView, openTable, settle };
}
@@ -1,6 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품)
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) ·
* 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c)
*
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
@@ -8,6 +9,7 @@
import "@ui/ui_template_workflow_layout.css";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
@@ -19,7 +21,8 @@ import {
titleOf,
} from "./M02_MasterTemplete_Layer";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import "./M02_MasterTemplete_UI_Style.css";
@@ -35,11 +38,30 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
// 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽
const tree = createTreePanel({
layer,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
const aside = el("aside", {
className: "m02-shell__tree",
children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root],
});
const main = createMain(
isAdmin,
() => void side.refresh(),
() => void Promise.all([side.refresh(), tree.refresh()]),
() => side.paint(),
{ top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) },
{
top: readOnlyBar?.root,
aside,
onColumn: (column) => {
readOnlyBar?.setColumn(column);
tree.select(column);
},
},
);
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
const home: Selection = {
@@ -74,4 +96,5 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
root.innerHTML = "";
root.append(layout);
void main.open(home);
void tree.refresh();
}
@@ -1,10 +1,12 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 집계 양식」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
* 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
import {
@@ -191,7 +193,8 @@ export function buildSide(opt: SideOptions): SideHandle {
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
text: info.이름,
// 구조물집계표 = 화면 이름 「구조물 조합」(26-7a)
text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => void select(info));
@@ -215,3 +215,53 @@
color: var(--color-text-muted);
font-size: 11px;
}
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
.m02-shell {
display: flex;
align-items: stretch;
min-width: 0;
}
.m02-shell > .m02-main {
flex: 1 1 0;
}
.m02-shell__tree {
display: flex;
flex: 0 0 16rem;
flex-direction: column;
box-sizing: border-box;
max-height: calc(100vh - var(--spacing-64));
padding: var(--spacing-16) var(--spacing-12);
border-right: 1px solid var(--color-border);
min-width: 0;
overflow: auto;
}
.m02-shell__tree[hidden] {
display: none;
}
.m02-shell__title {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-shell__tree .m02-tree__body {
max-height: none;
}
@media (max-width: 720px) {
.m02-shell {
flex-direction: column;
}
.m02-shell__tree {
flex-basis: auto;
max-height: 40vh;
border-right: 0;
border-bottom: 1px solid var(--color-border);
}
}
+68 -12
View File
@@ -1,12 +1,15 @@
"""프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e).
"""프로젝트 구조물 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 26-1 · 26-2b).
GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜)
POST …/items {layer, owner, key} 미리 가져오기(구조물에 안 이은 항목) → {폴더, 만듦, 가져온날}
GET …/items/{폴더}/{종류} {종류, 판, 내용}
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재.
폴더 = 구조물 id(`structure_id` · 관 지점은 `pipe@측점`) — 같은 항목을 고른 구조물도 복사본은 따로.
GET …/items 목록(원본 · 가져온날 · 마스터바뀜 · 원본없음)
POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409
GET …/items/{폴더}/{종류} {종류, 판, 내용}
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
"""
@@ -23,6 +26,7 @@ from common_util.common_util_auth import verify_session
from common_util.common_util_summary_item import parse_summary_item
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Source import item_name
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
@@ -36,8 +40,9 @@ class WriteBody(BaseModel):
class PrefetchBody(BaseModel):
"""`{layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음."""
"""`{structure, layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음."""
structure: str | None = None
layer: str | None = None
owner: str | None = None
key: str | None = None
@@ -45,6 +50,28 @@ class PrefetchBody(BaseModel):
summary_item: dict[str, Any] | None = None
class SaveAsBody(BaseModel):
to: Literal["personal"] = "personal"
경로: list[str] = []
이름: str | None = None
class NewBody(BaseModel):
structure: str
경로: list[str] = []
이름: str | None = None
단위: str | None = None
def _me(session: dict[str, Any]) -> tuple[int, int]:
"""개인 층 주인(회사 · 사용자) — 회사 없는 사용자는 409."""
if session.get("company_id") is None:
raise HTTPException(
status_code=409, detail="회사부터 등록 — 개인 층은 회사 연결이 필요합니다."
)
return int(session["company_id"]), int(session["user_id"])
async def _root(session: dict[str, Any], project_id: str):
return (await layer_routes._project(session, project_id))["root"]
@@ -52,6 +79,8 @@ async def _root(session: dict[str, Any], project_id: str):
async def _run(fn, *args):
try:
return await asyncio.to_thread(fn, *args)
except store.StoreError as error:
raise HTTPException(status_code=error.status, detail=error.detail) from error
except copies.Stale as error:
raise HTTPException(
status_code=409, detail={"message": str(error), "판": error.current}
@@ -73,6 +102,8 @@ async def list_items(
async def prefetch_item(
project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
if not body.structure:
raise HTTPException(status_code=400, detail="미리 가져올 구조물(structure)이 필요합니다.")
raw = body.summary_item or {"layer": body.layer, "owner": body.owner, "key": body.key}
try: # 모양 먼저(이름은 자리만) — 그다음 층 SS 줄에서 이름
item = parse_summary_item({**raw, "name": raw.get("name") or body.name or "-"})
@@ -83,7 +114,19 @@ async def prefetch_item(
item["name"] = item_name(item)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return await _run(copies.prefetch, await _root(session, project_id), item)
root = await _root(session, project_id)
return await _run(copies.prefetch, root, body.structure, item)
@router.post("/{project_id}/items/new")
async def new_item(
project_id: str, body: NewBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _root(session, project_id)
company, user = _me(session)
return await _run(
copies.new_item, root, body.structure, company, user, body.경로, body.이름, body.단위
)
@router.get("/{project_id}/items/{folder}/{kind}")
@@ -112,11 +155,24 @@ async def write_item_file(
return await _run(copies.write_file, root, folder, kind, body.판, body.내용, _recalc)
@router.post("/{project_id}/items/{folder}/reapply")
@router.post("/{project_id}/items/{folder}/refresh")
async def refresh_item(
async def reapply_item(
project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
return await _run(copies.refresh, await _root(session, project_id), folder)
return await _run(copies.reapply, await _root(session, project_id), folder)
@router.post("/{project_id}/items/{folder}/save-as")
async def save_item_as(
project_id: str,
folder: str,
body: SaveAsBody,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
root = await _root(session, project_id)
company, user = _me(session)
return await _run(copies.save_as, root, folder, company, user, body.경로, body.이름)
@router.post("/{project_id}/items/{folder}/calc")
+10 -4
View File
@@ -88,13 +88,15 @@ def collect_items(
) -> list[dict[str, Any]]:
"""설계 정본 → 구조물 목록.
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장}`.
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item,
복사본}` ·
`복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b).
"""
types = types or structure_type_map()
lengths = pipe_lengths or {}
items: list[dict[str, Any]] = []
_, structures = load_structures(str(project_root))
for item in structures:
for index, item in enumerate(structures):
definition = types.get(item.type_id)
if definition is not None and definition.managed_by:
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
@@ -107,6 +109,7 @@ def collect_items(
"end_m": item.end_m,
"options": _with_defaults(definition, dict(item.options or {})),
"summary_item": item.summary_item,
"복사본": item.structure_id or f"#{index}",
}
)
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
@@ -122,6 +125,7 @@ def collect_items(
"options": _with_defaults(types.get(type_id), dict(point.options or {})),
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
"summary_item": point.summary_item,
"복사본": f"pipe@{round(chainage, 3)}",
}
)
return items
@@ -593,7 +597,8 @@ def assign_items(
"""지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3).
- 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩
- 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값)
- 열 정의 = 그 구조물 복사본의 것(26-2b · 원본이 바뀌거나 지워져도 지정한 때 값)
- 시스템 층 항목 = 같은 id 열 자리
- 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게)
돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림).
"""
@@ -606,7 +611,8 @@ def assign_items(
unassigned = missing = 0
for item in items:
chosen = item.get("summary_item")
column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None
owner = item.get("복사본")
column = (read_snapshot(project_root, owner) or {}).get("열") if chosen and owner else None
if not chosen or not isinstance(column, dict) or not column.get("id"):
unassigned += not chosen
missing += bool(chosen)
+26 -4
View File
@@ -3,6 +3,10 @@
summary_item = {layer: "system"|"company"|"personal",
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"}
항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001`
(머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새
모양으로 옮기기 전에 적힌 기록(층 장부의 `옛키` 로 찾음).
구조물 기록(`structures.json` `StructureInstance`) · 관 지점 기록(`pipe_points.json` 점) 두 곳이
같은 칸을 씀 — 검사는 여기 한 벌. 없으면 null(옛 파일 호환).
"""
@@ -13,7 +17,11 @@ import re
from typing import Any
SUMMARY_LAYERS = ("system", "company", "personal")
_KEY = re.compile(r"^SS\d{6}$")
_KEY = re.compile(r"^SS\d{6}$") # 마스터 · 옛 키
_LAYER_KEY = {
"company": re.compile(r"^SC(\d+)-\d{6}$"),
"personal": re.compile(r"^SP(\d+)-\d{6}$"),
}
_OWNER = {
"system": None,
"company": re.compile(r"^\d+$"),
@@ -43,18 +51,32 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None:
shape = "{회사}" if layer == "company" else "{회사}/{사용자}"
raise ValueError(f"{layer} 층 집계 항목의 owner 는 「{shape}」 숫자 모양입니다.")
key = value.get("key")
if not isinstance(key, str) or not _KEY.match(key):
raise ValueError("집계 항목 key 는 SS 와 숫자 6자리입니다(예: SS000004).")
if not isinstance(key, str) or not _key_ok(layer, owner, key):
raise ValueError(
"집계 항목 key 는 마스터 SS000004 · 회사 SC{회사}-000001 · "
"개인 SP{사용자}-000001 모양입니다(옛 SS 키는 어느 층이든 받음)."
)
name = value.get("name")
if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX:
raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.")
return {"layer": layer, "owner": owner, "key": key, "name": name}
def _key_ok(layer: str, owner: Any, key: str) -> bool:
"""옛 · 마스터 SS 키는 어느 층이든 · SC · SP 는 그 층에서 머리 번호가 owner 와 같을 때만."""
if _KEY.match(key):
return True
rule = _LAYER_KEY.get(layer)
found = rule.match(key) if rule else None
if not found or not isinstance(owner, str):
return False
return found.group(1) == (owner if layer == "company" else owner.split("/")[-1])
def snapshot_folder_name(item: dict[str, Any]) -> str:
"""`{layer}_{owner 슬러그}_{key}` — owner 없음 = `none` · `/` = `-`.
예: `system_none_SS000004` · `company_3_SS000001` · `personal_3-12_SS000002`.
예: `system_none_SS000004` · `company_3_SC3-000001` · `personal_3-12_SP12-000002`.
"""
owner = item.get("owner")
slug = str(owner).replace("/", "-") if owner else "none"
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "be",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA14000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bg",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA22000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_area",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA12010200"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA12010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA05010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_wing",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA05010200"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c1",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA01010200"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c2",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA15010000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c3",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010600"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c5",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA01020000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "cs",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA24000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "ec",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA13010000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "etc",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA25010000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "fb",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA06010000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "fp_l",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA07010200"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "fp_w",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA07010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_curb",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA19000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_rail",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA18000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_sign",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA20000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_break",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA35000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_ford_basin",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA33000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_intake",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA26000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_pg_rail",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA31000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_pv_rail",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA30000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rip_c",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA27010000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rip_m",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA27020000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rockfall",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA34000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_soil_ditch",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA32000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_stone_bed",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA29000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_stone_ch",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA28000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_waste",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA36000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "mr",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA21000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "od_cnt",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA08010200"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "od_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA08010100"
}
-18
View File
@@ -1,18 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pg_in",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"계산값": {},
"면적번": "SA04010100"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pp_cp",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA03010200"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "ps",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA23000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_a",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010400"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_b",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010100"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_jt",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010500"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_l",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010200"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_w",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA02010300"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "rf",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA15000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "rw",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA09010100"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "sg",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA11010100"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "ta",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA17000000"
}
-17
View File
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "wy",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA16000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "be",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM14000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bg",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM22000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_area",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM12010200"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM12010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM05010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_wing",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM05010200"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c1",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM01010200"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c2",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM15010000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c3",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM02010600"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c5",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM01020000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "cs",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM24000000"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "ec",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM13010000"
}

Some files were not shown because too many files have changed in this diff Show More