Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")} 고름`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -158,6 +162,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
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:
|
||||
@@ -201,7 +207,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 +217,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 +235,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 +253,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 +298,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 +338,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 +424,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 +432,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 +461,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 +469,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()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""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 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,372 @@
|
||||
/* =============================================================================
|
||||
* 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) {
|
||||
if (error instanceof StaleError) {
|
||||
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));
|
||||
}
|
||||
@@ -24,6 +24,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 +92,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` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
|
||||
@@ -501,6 +506,28 @@ export function createMain(
|
||||
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;
|
||||
@@ -518,5 +545,5 @@ export function createMain(
|
||||
});
|
||||
bar();
|
||||
empty("");
|
||||
return { root, open, current: () => sel, confirmLeave, showView };
|
||||
return { root, open, current: () => sel, confirmLeave, showView, openTable, settle };
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
isAdmin,
|
||||
() => void side.refresh(),
|
||||
() => side.paint(),
|
||||
{ top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) },
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
onColumn: (column) => {
|
||||
readOnlyBar?.setColumn(column);
|
||||
side.selectItem(column);
|
||||
},
|
||||
},
|
||||
);
|
||||
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
|
||||
const home: Selection = {
|
||||
@@ -55,6 +61,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
showView: main.showView,
|
||||
tree: {
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: main.settle,
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
},
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { createTreePanel, type TreePanelOptions } from "./M02_MasterTemplete_TreePanel";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
@@ -71,6 +72,8 @@ export interface SideOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */
|
||||
tree: Pick<TreePanelOptions, "onPick" | "settle" | "onChanged">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -78,6 +81,8 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 표에서 고른 열을 항목 나무에 표시 */
|
||||
selectItem: (colId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -150,10 +155,15 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
// 항목 나무 — 구조물 집계 양식 아래 · 고르기 모달과 같은 폴더 나무(세 층 공용)
|
||||
const tree = createTreePanel({ layer, canEdit: canEdit(layer, opt.isAdmin), ...opt.tree });
|
||||
const treeSection = section(L("M02_TreeTitle"), tree.root);
|
||||
treeSection.classList.remove("is-collapsed");
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
treeSection,
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
...(layer === "system"
|
||||
? [
|
||||
@@ -279,7 +289,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await Promise.all([refreshStructure(), matset.refresh()]);
|
||||
await Promise.all([refreshStructure(), matset.refresh(), tree.refresh()]);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -360,5 +370,5 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint };
|
||||
return { root, refresh, paint, selectItem: tree.select };
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "be",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA14000000"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "c1",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA01010200"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "c2",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA15010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "c3",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010600"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "c5",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA01020000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "cs",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA24000000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ec",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA13010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "etc",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA25010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fb",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA06010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fp_l",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA07010200"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pg_in",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"계산값": {},
|
||||
"면적번": "SA04010100"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pp_cp",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA03010200"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ps",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA23000000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_a",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010400"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_b",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010100"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_jt",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010500"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_l",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010200"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "pv_w",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA02010300"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rf",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA15000000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rw",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA09010100"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "sg",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA11010100"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "ta",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA17000000"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "etc",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM25010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fb",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM06010000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fp_l",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM07010200"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "fp_w",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM07010100"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "gr_curb",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM19000000"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "gr_rail",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM18000000"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user