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

충돌 풂 — 양쪽 다 살림: M02 좌측(층 인식 도면 양식 + 구조물 옵션 보이기는 시스템 층만) · 스타일 · 사전(층 화면 + 항목 고르기) · B05 목록·패널 타입(공용 측점 표기 + 집계 항목 import)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
This commit is contained in:
2026-09-29 12:29:29 +09:00
co-authored by Claude Sonnet 5.5
54 changed files with 2447 additions and 237 deletions
+33 -2
View File
@@ -98,9 +98,39 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
)
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
#: 이름 속 측점 글 — 정수 m `00120` · 소수 측점 `00119.80`
CROSS_KEY_PATTERN = r"(\d+(?:\.\d{2})?)"
def cross_station(chainage_m: float) -> int | float:
"""측점 → 이름 열쇠 값 — 정수 m(0.005m 안)면 int(옛 이름 · 키 그대로) · 아니면 소수 2자리."""
value = round(float(chainage_m), 2)
whole = round(value)
return int(whole) if abs(value - whole) < 0.005 else value
def cross_key(chainage_m: float) -> str:
"""이름 속 측점 글 — `00120`(정수 · 옛 이름 그대로) · `00119.80`(소수 측점)."""
station = cross_station(chainage_m)
return f"{station:05d}" if isinstance(station, int) else f"{station:08.2f}"
def parse_cross_key(text: str) -> int | float:
"""`cross_key` 되읽기."""
return cross_station(float(text))
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
return f"cross_{cross_key(chainage_m)}m.json"
def find_cross_file(cross_dir: Path, chainage_m: float) -> Path:
"""읽기용 — 지금 이름이 없으면 옛 정수 m 이름(소수 측점이 정수로 붙던 옛 프로젝트)."""
path = cross_dir / cross_filename(chainage_m)
legacy = cross_dir / f"cross_{int(round(float(chainage_m))):05d}m.json"
return path if path.exists() or not legacy.exists() else legacy
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
@@ -109,8 +139,9 @@ def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
"""
# 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐
valid = {
cross_filename(station["chainage_m"])
find_cross_file(cross_dir, station["chainage_m"]).name
for station in stations
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
}
@@ -341,34 +341,19 @@ def generate_sections(
if 0.0 <= float(chainage) <= total
}
if extras:
# 횡단 파일명이 정수 미터 반올림이라(cross_filename) 한 정수 미터에 측점은 하나만 둔다.
# 관 · 구조물 자체 측점은 소수 그대로 — 격자 측점과 같은 정수 미터면 격자 대신 그 측점을
# 쓴다(시 · 종점 격자만 그대로 두고 라벨을 얹음). 비정규끼리 겹치면 앞선 것만 남긴다.
# ponytail: 파일명이 정수 미터인 동안의 가드. 소수 이름 규칙으로 바꾸면 지운다.
# 관 · 구조물 자리마다 그 소수 측점의 횡단을 따로 둔다(2026-09-29 사용자 확정) — 이름은
# 소수 2자리(cross_00119.80m · cross_filename). 같은 이름이 되는 0.005m 안 겹침만 하나로:
# 격자와 겹치면 격자 측점에 라벨을 얹고, 비정규끼리 겹치면 앞선 것만 남긴다.
grid = [round(float(value), 6) for value in station_chainage]
ends = {grid[0], grid[-1]}
buckets = {int(round(value)): value for value in grid}
taken: set[int] = set()
dropped: set[float] = set()
snapped: dict[float, str] = {}
for chainage in sorted(extras):
bucket = int(round(chainage))
if bucket in taken:
near = next((value for value in grid if abs(value - chainage) < 0.005), chainage)
if any(abs(near - taken) < 0.005 for taken in snapped):
continue
taken.add(bucket)
regular = buckets.get(bucket)
if regular in ends:
snapped[regular] = extras[chainage]
continue
if regular is not None:
dropped.add(regular)
snapped[chainage] = extras[chainage]
snapped[near] = extras[chainage]
extras = snapped
station_chainage = np.unique(
np.r_[
np.array([value for value in grid if value not in dropped], dtype=np.float64),
np.array(sorted(extras), dtype=np.float64),
]
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
+5 -10
View File
@@ -33,9 +33,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
StructureListResponse,
StructureSaveRequest,
StructureSaveResponse,
StructureTypesResponse,
load_structure_types,
registry_schema_version,
)
from common_util.common_util_drainage_pipes import (
PipePoint,
@@ -45,6 +42,7 @@ from common_util.common_util_drainage_pipes import (
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
logger = logging.getLogger(__name__)
@@ -59,13 +57,10 @@ _PROJECT_PATH_MISSING = {
}
@router.get("/structure-types", response_model=StructureTypesResponse)
async def read_structure_types() -> StructureTypesResponse:
"""구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용)."""
return StructureTypesResponse(
schema_version=registry_schema_version(),
types=list(load_structure_types()),
)
@router.get("/structure-types", response_model=TypesWithVisible)
async def read_structure_types() -> TypesWithVisible:
"""구조물 타입 레지스트리 정본 + 옵션마다 `visible`(M02 옵션 보이기 · 폼 생성용)."""
return with_visible()
async def _project_root(project_id: UUID) -> str | None:
@@ -10,6 +10,7 @@
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
* ========================================================================== */
import { stationPartsLabel } from "../common_util/common_util_station";
import {
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
ESCAPE_ROUTE_WIDTHS_M,
@@ -130,9 +131,9 @@ export function irregularStationId(id: string): string {
return `irregular:${id}`;
}
/** 측점번호+잔여거리 표기 (예: 3+18.0). */
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
export function irregularLabel(station: IrregularStation): string {
return `${station.station}+${station.remainder.toFixed(1)}`;
return stationPartsLabel(station.station, station.remainder);
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
@@ -24,7 +24,7 @@ import {
designHalfWidths,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface CrossPreviewParams {
projectId: string;
@@ -13,7 +13,7 @@
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
type Vertex = [number, number];
@@ -26,7 +26,7 @@ import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 노드 반지름(px). */
const NODE_R = 4;
@@ -14,7 +14,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item";
export interface StructureListParams {
@@ -20,7 +20,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
+10 -28
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
isB05Option,
structureAnchorM,
type StructureInstance,
type StructuresSectionOptions,
@@ -38,13 +37,19 @@ import {
loadPipeForm as loadPipeFormInto,
} from "./B05_Profile_UI_Structures_Panel_Commit";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
import {
DEFAULT_INTERVAL_LENGTH_M,
GROUP_LABELS,
hasComputedRange,
readBeforeOption,
readLengthOption,
} from "./B05_Profile_UI_Structures_Panel_Types";
import type {
PipeFacilityItem,
StructuresCallbacks,
@@ -92,35 +97,12 @@ export function createStructuresSection(
return type.managed_by ? (type.type_id as PipeFacility) : null;
}
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
function hasComputedRange(type: StructureType | null): boolean {
return (
!!type &&
!type.managed_by &&
type.placement === "interval" &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
function readLengthM(): number | null {
const entry = optionInputs.find((input) => input.key === "length_m");
if (!entry || entry.isEmpty()) return null;
const length = Number(entry.read());
return Number.isFinite(length) && length > 0 ? length : null;
return readLengthOption(optionInputs);
}
/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
* (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
function readBeforeM(): number {
const entry = optionInputs.find((input) => input.key === "before_m");
if (!entry || entry.isEmpty()) return 0;
const before = Number(entry.read());
if (!Number.isFinite(before) || before < 0) return 0;
const length = readLengthM();
return length !== null && before > length ? length : before;
return readBeforeOption(optionInputs);
}
/** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */
@@ -13,7 +13,11 @@ import type {
PipeSource,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import {
isB05Option,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { SummaryItem } from "../common_util/common_util_summary_item";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
@@ -124,3 +128,36 @@ export interface StructuresCallbacks {
*/
onReveal?: () => void;
}
type OptionEntry = { key: string; read: () => string | number; isEmpty: () => boolean };
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
export function readLengthOption(inputs: OptionEntry[]): number | null {
const entry = inputs.find((input) => input.key === "length_m");
if (!entry || entry.isEmpty()) return null;
const length = Number(entry.read());
return Number.isFinite(length) && length > 0 ? length : null;
}
/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
* (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
export function readBeforeOption(inputs: OptionEntry[]): number {
const entry = inputs.find((input) => input.key === "before_m");
if (!entry || entry.isEmpty()) return 0;
const before = Number(entry.read());
if (!Number.isFinite(before) || before < 0) return 0;
const length = readLengthOption(inputs);
return length !== null && before > length ? length : before;
}
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
export function hasComputedRange(type: StructureType | null): boolean {
return (
!!type &&
!type.managed_by &&
type.placement === "interval" &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
+2 -7
View File
@@ -4,7 +4,8 @@
*
* 구조물 위치는 저장은 누가거리(m)로 하되 **화면 입출력은 측점번호+잔여거리**로 한다
* (2026-08-17 사용자 확정). 근거: 중심선측량 측점 20m + 구조물 지점 보조말뚝
* (지식DB 설계제원_총괄 §8). 표기 예: "3+18.0" = 측점 3번에서 18.0m.
* (지식DB 설계제원_총괄 §8). 표기 예: "3+18" = 측점 3번에서 18m — 화면 표기는
* `common_util_station.stationLabel` 한 벌(거리는 정수로 보이기만 · 값은 소수).
*
* 변환식은 비정규 측점 UI(`B05_Profile_UI_IrregularStations.ts`)와 같다 —
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 호출 시점에 받는다
@@ -33,12 +34,6 @@ export function chainageToStation(
return { station, remainder };
}
/** 누가거리 → "3+18.0" 표기. */
export function formatStation(chainageM: number, intervalM: number): string {
const { station, remainder } = chainageToStation(chainageM, intervalM);
return `${station}+${remainder.toFixed(1)}`;
}
/** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다.
* 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */
export function parseStationText(text: string, intervalM: number): number | null {
@@ -19,7 +19,7 @@ from typing import Any, Callable
import numpy as np
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
@@ -175,7 +175,7 @@ def extend_unclosed_sections(
recomputed[key] = design[key]
section["samples"] = samples
section["design"] = recomputed
cross_path = cross_dir / cross_filename(chainage)
cross_path = find_cross_file(cross_dir, chainage)
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": samples})
+10 -10
View File
@@ -406,23 +406,23 @@ async def merge_cross_section_design_by_round(
connection: aiomysql.Connection,
*,
route_id: int,
chainage_int: int,
chainage: float,
patch: dict[str, Any],
) -> bool:
"""정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다.
"""측점(도면 id 측점 — 정수 m 또는 소수 2자리)의 data.design에 patch를 병합 저장한다.
B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다.
도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다.
이름 규칙(cross_key)의 0.005m 안으로 매칭한다. 갱신 여부를 반환한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
(route_id, chainage),
)
row = await cursor.fetchone()
if not row:
@@ -445,21 +445,21 @@ async def merge_cross_section_design_by_round(
async def get_cross_section_design(
connection: aiomysql.Connection, route_id: int, chainage_int: int
connection: aiomysql.Connection, route_id: int, chainage: float
) -> dict[str, Any] | None:
"""정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다.
"""도면 id 측점(cross_{정수m}m · cross_{소수2자리}m)의 잠정 설계(data.design)를 반환한다.
B08이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다.
이름 규칙(cross_key)의 0.005m 안으로 매칭한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
(route_id, chainage),
)
row = await cursor.fetchone()
if not row or not row[0]:
+3 -3
View File
@@ -5,7 +5,7 @@ import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
@@ -309,7 +309,7 @@ def read_cross_design_inputs(
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
suggested = pavement.get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
cross_path = find_cross_file(cross_dir, chainage_m).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
@@ -492,7 +492,7 @@ def compute_default_designs(
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
cross_path = find_cross_file(cross_dir, chainage_m)
if not cross_path.is_file():
continue
try:
@@ -9,6 +9,7 @@
import re
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_MM,
CROSS_TABLE_LAYER_ID,
@@ -38,7 +39,7 @@ from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거).
@@ -151,7 +152,7 @@ def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[flo
return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
@@ -170,7 +171,7 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m",
"id": f"cross_s{cross_key(chainages[0])}m",
"number": len(sheets) + 1,
"chainages": chainages,
"rows": max(rows, 1),
+20 -15
View File
@@ -13,6 +13,11 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections import (
CROSS_KEY_PATTERN,
cross_station,
parse_cross_key,
)
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_design,
@@ -65,7 +70,7 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
@@ -249,13 +254,13 @@ async def title_block_fields(project_id: UUID) -> dict[str, str]:
return {key: str(value) for key, value in fields.items() if value}
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
async def _designs_by_chainage(route_id: int) -> dict[int | float, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await get_cross_section_designs(connection, route_id)
return {
int(round(float(row["chainage_m"]))): row["design"]
cross_station(row["chainage_m"]): row["design"]
for row in rows
if isinstance(row, dict) and isinstance(row.get("design"), dict)
}
@@ -304,7 +309,7 @@ async def get_design_drawing(
pool = get_db_pool()
async with pool.acquire() as connection:
design = await get_cross_section_design(
connection, route_id, int(cross_match.group(1))
connection, route_id, parse_cross_key(cross_match.group(1))
)
source_design = design
elif CROSS_SHEET_ID.fullmatch(drawing_id):
@@ -448,25 +453,25 @@ async def confirm_design_drawing(
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
targets: list[int | float] = []
if sheet is not None:
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
targets = [parse_cross_key(cross_match.group(1))]
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
(chainage, {**designs[chainage], "status": "confirmed"})
for chainage in targets
if designs.get(chainage)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, _design in confirmed_designs:
for chainage, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
chainage=chainage,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
@@ -519,9 +524,9 @@ async def invalidate_design_drawing(
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
stale: list[int] = []
stale: list[int | float] = []
if cross_match:
stale = [int(cross_match.group(1))]
stale = [parse_cross_key(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
@@ -533,11 +538,11 @@ async def invalidate_design_drawing(
await connection.begin()
try:
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
for chainage_int in stale:
for chainage in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
chainage=chainage,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
@@ -9,6 +9,12 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import (
CROSS_KEY_PATTERN,
cross_station,
find_cross_file,
parse_cross_key,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
@@ -94,7 +100,7 @@ logger = logging.getLogger(__name__)
_STAGE_DIR = "B07_DesignDetail"
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
@@ -107,7 +113,7 @@ CROSS_STANDARD_ID = "cross_standard"
def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -226,7 +232,7 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
@@ -239,8 +245,9 @@ def _cross_sheet_plan(
match = _CROSS_ID.fullmatch(path.stem)
if not match:
continue
chainage = int(match.group(1))
source = _read_json(path)
# 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line)
@@ -352,11 +359,11 @@ def _cross_design_line(
def _cross_section_input(
longitudinal_path: Path,
longitudinal: dict[str, Any],
chainage: int,
chainage: int | float,
design: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
if not path.is_file():
return None
source = _read_json(path)
@@ -565,9 +572,12 @@ def _read_drawing(
if CROSS_SHEET_ID.fullmatch(drawing_id):
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
if not _CROSS_ID.fullmatch(drawing_id):
cross_match = _CROSS_ID.fullmatch(drawing_id)
if not cross_match:
raise ValueError("올바르지 않은 도면 ID입니다.")
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
path = find_cross_file(
longitudinal_path.parent.parent / "cross_sections", parse_cross_key(cross_match.group(1))
)
if not path.is_file():
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
@@ -9,7 +9,7 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
from B05_Profile.B05_Profile_Engine_Sections import cross_station, prune_stale_cross_files
logger = logging.getLogger(__name__)
@@ -47,12 +47,12 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[
return files
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
def _station_map(longitudinal: dict[str, Any]) -> dict[int | float, dict[str, Any]]:
stations = longitudinal.get("stations", [])
if not isinstance(stations, list):
return {}
return {
round(float(station.get("chainage_m", 0))): station
cross_station(station.get("chainage_m", 0)): station
for station in stations
if isinstance(station, dict)
}
+1 -1
View File
@@ -178,7 +178,7 @@ export function buildSide(
children: [
el("span", {
children: [
el("span", { className: "m01-tree__caret", text: caret }),
el("span", { className: "ui-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
@@ -17,24 +17,6 @@
color: var(--color-accent);
}
.m01-tree__kids {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__caret {
display: inline-block;
width: var(--spacing-12);
color: var(--color-text-muted);
}
.m01-side__muted,
.m01-master__muted {
color: var(--color-text-muted);
+2 -40
View File
@@ -1,44 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Tree.ts
* 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시
* 왼쪽 패널 트리 — 공용 `ui_template_tree` 를 그대로 다시 내보냄(부르는 쪽 import 불변)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
export interface TreeNode {
id: string;
label: string;
/** 줄 수 — null 이면 안 보임 */
count: number | null;
/** 눌렀을 때 — 없으면 접기/펴기만 */
run?: () => void;
children?: TreeNode[];
/** 처음부터 펴짐 */
open?: boolean;
}
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
export type MakeRow = (
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
) => HTMLElement;
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
return nodes.map((node) => {
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
const kids = el("div", {
className: "m01-tree__kids",
children: renderTree(node.children, make, depth + 1),
});
kids.hidden = !node.open;
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".m01-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
node.run?.();
});
return el("div", { className: "m01-tree__branch", children: [row, kids] });
});
}
export { renderTree, type MakeRow, type TreeNode } from "@ui/ui_template_tree";
@@ -259,6 +259,29 @@ export const editMaterialCombo = (
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
export const fetchStructureOptions = (): Promise<OptionsDoc> => call("/structure-options");
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
export const copyStructure = async (column: string, from: string): Promise<void> => {
await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
@@ -0,0 +1,130 @@
"""M02 층 집계표 항목 — 회사 · 개인 층 빈 뼈대 · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) · 자재조합(UM) ·
도번 SD · 면적번 SA · 재료번 SM.
층 폴더(시스템 · 회사 · 개인)는 모양이 같아 저장소 함수를 `store.at(층 폴더)` 안에서 그대로 부름.
권한은 부르는 쪽(`M02_MasterTemplete_Router_Items.py`) 몫 — 여기는 폴더와 파일만.
"""
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_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
ITEM_FIELDS = (
"열",
"종류",
"분류1",
"분류2",
"분류3",
"단위",
"일위대가",
"자재조합",
"도번",
"면적번",
"재료번",
)
def ensure(folder: Path) -> bool:
"""회사 · 개인 층 첫 진입 — 빈 집계표(열 0 · 층 이름 · 줄 머리는 시스템 것) ·
조합 장부(`다음` 1) · 자재 조합 빈 표 · 종류 폴더.
이미 집계표가 있으면 아무것도 안 함(시스템 층은 늘 있음)."""
table = Path(folder) / "table" / f"{store.STRUCTURE_TABLE}.json"
if table.is_file():
return False
system = json.loads((store.FOLDER / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes())
doc = {
**system,
"열": [],
"줄": [{**r, "값": {}} for r in system.get("줄", []) if isinstance(r, dict)],
"합계줄": [],
"보기": {},
}
with store.at(folder):
for kind in store.KINDS:
(Path(folder) / kind).mkdir(parents=True, exist_ok=True)
with store._LOCK:
if table.is_file(): # 그 사이 다른 요청이 만듦
return False
atomic_write_json(table, doc)
store.sync_combos()
mat = Path(folder) / matset.FILE
if not mat.is_file():
atomic_write_json(mat, matset._load()[0])
return True
def items(folder: Path) -> list[dict[str, Any]]:
"""그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번}`."""
with store.at(folder):
rows = store.read_combos().get("줄", [])
return [{"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}} for r in rows]
def _fresh_id(want: str, used: set[str]) -> str:
out, n = want, 2
while out in used:
out, n = f"{want}_{n}", n + 1
return out
def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]:
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 ·
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 자재 조합 줄(새 UM) 복사 ·
새 번호 · 새 SS 키 ·
열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`."""
with store.at(source):
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
col_id = row["열"]
col = next(c for c in store._structure_columns() if c["id"] == col_id)
docs = {}
for kind, _, _, _ in store.PARTS:
try:
docs[kind] = store.read(kind, col_id)["문서"]
except store.StoreError:
pass # 없는 파일은 새 층에서 빈 문서로 섬
um = None
if col.get("자재조합"):
try:
um = matset.one(col["자재조합"])
except store.StoreError:
um = None # 가리키던 줄이 없으면 비움
ensure(target)
with store.at(target):
new_um = None
if um:
names = matset.listing()["줄"]
taken = {str(r.get("이름") or "") for r in names}
name, n = str(um.get("이름") or key), 2
base = name
while name in taken:
name, n = f"{base} ({n})", n + 1
got = {k: um.get(k) for k in ("담은자재", "소유", "비고")}
new_um = matset.create({**got, "이름": name, "본뜬키": None})["키"]
table = store.read("table", store.STRUCTURE_TABLE)
doc = table["문서"]
new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)})
made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin}
doc["열"] = [*doc.get("열", []), made]
store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS
for kind, got in docs.items():
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)
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)}
@@ -22,7 +22,7 @@ _HEAD = "UM"
def _load() -> tuple[dict[str, Any], str]:
"""(표, 판) — 파일이 없으면 빈 표 · 판 빈 글."""
path = store.FOLDER / FILE
path = store.root() / FILE
if not path.is_file():
return {"그룹": mmc.GROUP, "판": "2026", "비고": [], "다음": 1, "줄": []}, ""
raw = path.read_bytes()
@@ -95,7 +95,7 @@ def _save(doc: dict, version: str) -> str:
"""틀 검사 → 판이 같을 때만 씀 → 새 판."""
if errors := mmc.check_form(FILE, doc):
raise store.StoreError(422, {"errors": errors})
path = store.FOLDER / FILE
path = store.root() / FILE
with store._LOCK:
have = store.version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
@@ -0,0 +1,149 @@
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import Field
from B05_Profile.B05_Profile_Structures_Schema import (
StructureOptionField,
StructureType,
StructureTypesResponse,
load_structure_types,
registry_schema_version,
)
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "구조물옵션표시.json"
logger = logging.getLogger(__name__)
class OptionWithVisible(StructureOptionField):
"""폼 옵션 한 칸 + 보임 여부(서버가 채움)."""
visible: bool = True
class TypeWithVisible(StructureType):
options: list[OptionWithVisible] = Field(default_factory=list)
class TypesWithVisible(StructureTypesResponse):
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
types: list[TypeWithVisible]
def _load() -> tuple[dict[str, Any], str]:
"""(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
path = store.FOLDER / FILE
if not path.is_file():
return {}, ""
raw = path.read_bytes()
try:
doc = json.loads(raw)
except ValueError as e:
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
if not isinstance(doc, dict):
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
return doc, store.version_of(raw)
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
row = doc.get(type_id)
return not (isinstance(row, dict) and row.get(key) is False)
def with_visible() -> TypesWithVisible:
"""레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만)."""
try:
doc = _load()[0]
except store.StoreError as e:
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
doc = {}
types = [
{
**t.model_dump(),
"options": [
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
],
}
for t in load_structure_types()
]
return TypesWithVisible.model_validate(
{"schema_version": registry_schema_version(), "types": types}
)
def listing() -> dict[str, Any]:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션."""
doc, version = _load()
hidden: dict[str, dict[str, bool]] = {}
kinds = []
for t in load_structure_types():
if not t.options:
continue
off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)}
if off:
hidden[t.type_id] = off
kinds.append(
{
"type_id": t.type_id,
"group": t.group,
"name": t.name,
"enabled": t.enabled,
"options": [
{"key": o.key, "label": o.label, "phase": o.phase, "required": o.required}
for o in t.options
],
}
)
return {"판": version, "표시": hidden, "종류": kinds}
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
if not isinstance(shown, dict):
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
errors: list[str] = []
out: dict[str, dict[str, bool]] = {}
for type_id, row in shown.items():
if type_id not in keys:
errors.append(f"없는 구조물 종류 「{type_id}」")
elif not isinstance(row, dict):
errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함")
else:
for key, value in row.items():
if key not in keys[type_id]:
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
elif not isinstance(value, bool):
errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함")
elif not value:
out.setdefault(type_id, {})[key] = False
if errors:
raise store.StoreError(422, {"errors": errors})
return out
def save(shown: Any, version: str) -> dict[str, Any]:
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
fixed = _clean(shown)
path = store.FOLDER / FILE
with store._LOCK:
have = store.version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
atomic_write_json(path, fixed)
return listing()
@@ -0,0 +1,220 @@
/* =============================================================================
* M02_MasterTemplete_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import {
fetchStructureOptions,
saveStructureOptions,
StaleError,
type OptionKind,
type OptionsDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import { to } from "./M02_MasterTemplete_Options_Text";
export interface OptionsSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface OptionsSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: to("Row"),
attrs: { type: "button", "data-options-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
row.addEventListener("click", () => void open());
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
let doc: OptionsDoc;
try {
doc = await fetchStructureOptions();
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
draw(doc);
}
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
function draw(doc: OptionsDoc): void {
let version = doc.판;
const edit = opt.isAdmin;
/** 숨긴 옵션 — 종류 → 키 집합 */
const hidden = new Map<string, Set<string>>();
const load = (src: OptionsDoc["표시"]): void => {
hidden.clear();
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
};
load(doc.표시);
let kinds = doc.종류;
let current: OptionKind | null = null;
const kindList = el("div", { className: "m02-opts__kinds" });
const optionList = el("div", { className: "m02-opts__list" });
const off = (id: string): number => hidden.get(id)?.size ?? 0;
const paintKinds = (): void => {
const parts: HTMLElement[] = [];
let group = "";
for (const k of kinds) {
if (k.group !== group) {
group = k.group;
parts.push(
el("h4", {
className: "m02-opts__group",
text: group.length === 1 ? to("Group", group) : group,
}),
);
}
const n = off(k.type_id);
const b = el("button", {
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
attrs: { type: "button", "data-options-kind": k.type_id },
children: [
el("span", { text: k.name }),
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
],
});
b.addEventListener("click", () => {
current = k;
paintKinds();
paintOptions();
});
parts.push(b);
}
kindList.replaceChildren(...parts);
};
const paintOptions = (): void => {
if (!current) {
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const kind = current;
optionList.replaceChildren(
el("h3", { className: "m02-matset__head", text: kind.name }),
...kind.options.map((o) => {
const box = el("input", {
attrs: { type: "checkbox", "data-options-key": o.key },
}) as HTMLInputElement;
box.checked = !hidden.get(kind.type_id)?.has(o.key);
box.disabled = !edit;
box.addEventListener("change", () => {
const set = hidden.get(kind.type_id) ?? new Set<string>();
if (box.checked) set.delete(o.key);
else set.add(o.key);
hidden.set(kind.type_id, set);
setDirty(true);
paintKinds();
});
const tags: HTMLElement[] = [];
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
if (o.required && o.phase === "b05") {
const tag = createTag(to("Required"), "danger");
tag.title = to("RequiredTip");
tags.push(tag);
}
return el("label", {
className: "m02-opts__option",
children: [
box,
el("span", { text: o.label }),
el("small", { className: "m02-opts__key", text: o.key }),
...tags,
],
});
}),
);
};
const save = createButton({ label: to("Save"), variant: "filled" });
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
all.addEventListener("click", () => {
if (![...hidden.values()].some((s) => s.size)) return;
hidden.clear();
setDirty(true);
paintKinds();
paintOptions();
});
save.addEventListener("click", async () => {
const 표시: OptionsDoc["표시"] = {};
for (const [id, keys] of hidden)
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
save.disabled = true; // 연달아 눌러도 한 번만
try {
const done = await saveStructureOptions(version, 표시);
version = done.판;
load(done.표시);
setDirty(false);
showToast(to("Saved"), "success");
paintKinds();
paintOptions();
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
try {
const fresh = await fetchStructureOptions();
version = fresh.판;
kinds = fresh.종류;
load(fresh.표시);
setDirty(false);
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
paintKinds();
paintOptions();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
} else showToast(to("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-opts",
children: [
el("p", { className: "m02-side__empty", text: to("Note") }),
el("div", {
className: "m02-opts__panes",
children: [kindList, optionList],
}),
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
],
});
const setDirty = opt.show(to("Screen"), node);
paintKinds();
paintOptions();
opt.onOpen();
mark(true);
}
return { title: to("Title"), body, clear: () => mark(false) };
}
@@ -0,0 +1,43 @@
/* =============================================================================
* M02_MasterTemplete_Options_Text.ts
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["구조물 옵션 보이기", "Structure options shown"],
Row: ["종류마다 보일 옵션 고르기", "Choose options per type"],
Screen: ["구조물 옵션 보이기", "Structure options shown"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
],
Group: ["{value}군", "Group {value}"],
Hidden: ["숨김 {value}", "{value} hidden"],
Off: ["안 쓰는 종류", "Not offered"],
NoOption: ["옵션 없음", "No options"],
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Required: ["필수", "Required"],
RequiredTip: [
"필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음",
"Required — hiding it leaves the B05 form unable to set it",
],
Save: ["저장", "Save"],
ShowAll: ["모두 보임", "Show all"],
Saved: ["저장함", "Saved"],
Stale: [
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",
],
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;
export function to(key: OptionsTextKey, value = ""): string {
const entry = TEXT[key];
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
}
@@ -12,6 +12,7 @@ from pydantic import BaseModel
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Options as options
from M02_MasterTemplete import M02_MasterTemplete_Store as store
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
@@ -41,6 +42,11 @@ class MatsetBody(BaseModel):
줄: dict[str, Any]
class OptionsBody(BaseModel):
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
def _call(fn, *args):
try:
return fn(*args)
@@ -127,6 +133,19 @@ def delete_material_combo(key: str, 판: str = "") -> dict:
return _call(matset.delete, key, 판)
# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`)
@router.get("/structure-options")
def get_structure_options() -> dict:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
return _call(options.listing)
@router.put("/structure-options")
def put_structure_options(body: OptionsBody) -> dict:
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
return _call(options.save, body.표시, body.판)
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
@router.post("/structures/{column}/copy")
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
@@ -0,0 +1,263 @@
"""M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
⚠ 등록은 `main.py`(로그인만) · 층 권한은 여기서(`Router_Layers._layer_dir` 와 같은 규칙):
system 읽기 = 로그인 누구나(항목 고르기) · 쓰기 = SYSTEM_ADMIN
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN)
personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」
옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로.
회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
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_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router import (
BranchBody,
MaterialBranchBody,
MatsetBody,
UseComboBody,
)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"])
Layer = Literal["system", "company", "personal"]
Session = dict[str, Any]
NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음"
class CopyItemBody(BaseModel):
from_layer: Literal["system", "company"]
from_owner: str | None = None
key: str
def _admin(session: Session) -> bool:
return session.get("role") == "SYSTEM_ADMIN"
def _company_admin(session: Session) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
def _owner(session: Session, layer: str, owner: str | None) -> str | None:
"""층 주인 글 — 회사 `{회사}` · 개인 `{회사}/{사용자}` · 시스템 None ·
남의 것은 SYSTEM_ADMIN 만."""
if layer == "system":
return None
company, user = session.get("company_id"), session.get("user_id")
mine = str(company) if layer == "company" else f"{company}/{user}"
if owner in (None, "", mine):
return None if company is None else mine
if not _admin(session):
raise HTTPException(status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표입니다.")
return owner
def _folder(layer: str, owner: str | None) -> Path:
if layer == "system":
return store.FOLDER
try:
if layer == "company":
return layers.company_dir(owner)
company, user = str(owner).split("/", 1)
return layers.personal_dir(company, user)
except ValueError as error:
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
"""층 폴더(권한까지) — 회사 · 개인은 없으면 빈 뼈대를 만듦. 회사 없는 사람: 쓰기 409."""
if layer == "system":
if write and not _admin(session):
raise HTTPException(status_code=403, detail="마스터 템플릿은 시스템 관리자만 고침.")
return store.FOLDER
who = _owner(session, layer, owner)
if who is None:
raise HTTPException(status_code=409, detail=NO_COMPANY)
if write and layer == "company" and not _company_admin(session):
raise HTTPException(
status_code=403, detail="회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
)
folder = _folder(layer, who)
items_store.ensure(folder)
return folder
def _call(folder: Path, fn, *args):
with store.at(folder):
try:
return fn(*args)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
# ── 항목 목록 · 복사 ───────────────────────────────────
def _listing(session: Session, layer: str, owner: str | None) -> dict[str, Any]:
who = None if layer == "system" else _owner(session, layer, owner)
if layer != "system" and who is None:
return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹
folder = _root(session, layer, who)
return {"layer": layer, "owner": who, "items": items_store.items(folder)}
@router.get("/items")
def get_items(
layer: Layer = "system",
owner: str | None = None,
all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1`
session: Session = Depends(verify_session),
):
"""21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번,
재료번}]` ·
`?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록)."""
if all:
return [_listing(session, one, None) for one in ("system", "company", "personal")]
return _listing(session, layer, owner)["items"]
@router.post("/layers/personal/items/copy")
def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict:
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`)."""
target = _root(session, "personal", None, write=True)
source = _root(session, body.from_layer, body.from_owner)
owner = (
None if body.from_layer == "system" else _owner(session, body.from_layer, body.from_owner)
)
origin = {"layer": body.from_layer, "owner": owner, "key": body.key}
try:
return items_store.copy_item(source, target, body.key, origin)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
@router.get("/layers/{layer}/structures/numbers")
def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), store.structure_numbers)
@router.get("/layers/{layer}/structure-combos")
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), store.read_combos)
@router.post("/layers/{layer}/structures/{column}")
def post_structure(
layer: Layer, column: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_root(session, layer, owner, write=True), store.create_structure, column)
@router.post("/layers/{layer}/structures/{column}/combo")
def post_combo(
layer: Layer,
column: str,
body: BranchBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만."""
folder = _root(session, layer, owner, write=True)
if not _admin(session):
raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.")
return _call(
folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판
)
@router.post("/layers/{layer}/structures/{column}/material-combo")
def post_material_combo_branch(
layer: Layer,
column: str,
body: MaterialBranchBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
args = (column, body.본뜬키, body.담은자재, body.이름, body.판)
return _call(folder, branch.branch_material_combo, *args)
@router.post("/layers/{layer}/structures/{column}/use-combo")
def post_use_combo(
layer: Layer,
column: str,
body: UseComboBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, branch.use_combo, column, body.종류, body.키, body.판)
@router.post("/layers/{layer}/{kind}/{column}/copy")
def post_copy(
layer: Layer,
kind: Literal["structures", "area", "material"],
column: str,
source: str = Query(alias="from"),
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
part = "structure" if kind == "structures" else kind
return _call(folder, store.copy_from, part, column, source)
@router.get("/layers/{layer}/material-combos")
def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), matset.listing)
@router.get("/layers/{layer}/material-combos/{key}")
def get_matset(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_root(session, layer, owner), matset.one, key)
@router.post("/layers/{layer}/material-combos")
def post_matset(
layer: Layer,
body: MatsetBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판)
@router.put("/layers/{layer}/material-combos/{key}")
def put_matset(
layer: Layer,
key: str,
body: MatsetBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판)
@router.delete("/layers/{layer}/material-combos/{key}")
def delete_matset(
layer: Layer,
key: str,
판: str = "",
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.delete, key, 판)
@@ -1,7 +1,7 @@
"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」.
⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서:
system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길)
system 읽기 · 쓰기는 시스템 관리자(옛 `/api/m02/templates` 길과 같은 저장소)
company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자)
personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기)
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀
@@ -26,6 +26,8 @@ from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Table_Fill as fill
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -152,8 +154,8 @@ async def _project(session: dict[str, Any], project_id: str | None) -> dict[str,
def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int:
company_id = project["company_id"] if project else session.get("company_id")
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
if company_id is None: # 회사 없는 사용자는 없음(21-0) — 회사 · 개인 층은 회사부터
raise HTTPException(status_code=409, detail="회사부터 등록 — 회사 연결이 필요합니다.")
return int(company_id)
@@ -174,8 +176,8 @@ async def _layer_dir(
) -> Path:
"""층 폴더 — 읽기·쓰기 권한까지 여기서 가름."""
if layer == "system":
if write:
raise HTTPException(status_code=403, detail="시스템 양식은 마스터 템플릿 화면에서만.")
if write and not _is_system_admin(session):
raise HTTPException(status_code=403, detail="시스템 양식은 시스템 관리자만.")
return layers.system_dir()
if layer == "project":
return layers.project_dir((await _project(session, project_id))["root"])
@@ -184,16 +186,22 @@ async def _layer_dir(
if layer == "company":
if write and not _is_company_admin(session):
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
return layers.company_dir(company_id)
return _ensured(layers.company_dir(company_id))
if layer == "personal":
owner = int(user_id) if user_id is not None else int(session["user_id"])
if write and owner != session.get("user_id"):
raise HTTPException(status_code=403, detail="개인 양식은 본인만 고칩니다.")
await _same_company_user(session, owner, company_id)
return layers.personal_dir(company_id, owner)
return _ensured(layers.personal_dir(company_id, owner))
raise HTTPException(status_code=404, detail="없는 층입니다.")
def _ensured(folder: Path) -> Path:
"""회사 · 개인 층 첫 진입 — 빈 구조물집계표 뼈대(PLAN 21-2 · `Items.ensure`)."""
items_store.ensure(folder)
return folder
def _bad(error: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(error))
@@ -261,6 +269,13 @@ async def save_layer_template(
if kind == "table" and fill.is_fillable(document):
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
document = fill.strip_design(document)
if layer != "project":
# 시스템 · 회사 · 개인 층 = 마스터 저장소 규칙 그대로(집계표 저장 때 새 열 파일 ·
# 번호 · SS 장부 ·
# 계산값 서버 풀이 · 번호 칸 보존 — `M02_MasterTemplete_Store.write`)
return await asyncio.to_thread(
_store_save, folder, kind, name, body.판 or "", document, layer
)
if kind in ("area", "material"):
# 면적산출 · 재료산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
# (브라우저 값 버림 · 5장 ②)
@@ -287,6 +302,15 @@ async def save_layer_template(
return {"종류": kind, "이름": name, "판": version, "층": layer}
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
with store.at(folder):
try:
info = store.write(kind, name, version, doc)
except store.StoreError as error:
raise HTTPException(status_code=error.status, detail=error.detail) from error
return {"종류": kind, "이름": name, "판": info["판"], "층": layer}
@router.delete("/layers/{layer}/templates/{kind}/{name}")
async def delete_layer_template(
layer: Layer,
@@ -296,10 +320,15 @@ async def delete_layer_template(
project_id: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 · 프로젝트 층은 거절 · 판이 다르면 409."""
if layer in ("system", "project"):
raise HTTPException(status_code=403, detail="개인 · 회사 층 양식만 지울 수 있습니다.")
"""개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 = 시스템 관리자 · 프로젝트 층은 거절 ·
판이 다르면 409 · 프로그램이 이름으로 부르는 양식(`store.IN_USE` — 구조물집계표 등)은 409."""
if layer == "project":
raise HTTPException(
status_code=403, detail="개인 · 회사 · 시스템 층 양식만 지울 수 있습니다."
)
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
if (kind, name) in store.IN_USE:
raise HTTPException(status_code=409, detail=f"쓰는 양식이라 못 지움 「{name}」")
try:
layers.check_kind(kind)
layers.check_name(name)
+34 -11
View File
@@ -1,4 +1,4 @@
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`).
"""M02 마스터 템플릿 — 층 저장소 · 기본 시스템 층(`resources/master_template/{종류}/<이름>.json`).
M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기.
권한은 등록하는 쪽(`main.py`)이 붙임.
@@ -10,6 +10,9 @@ import hashlib
import json
import re
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -20,6 +23,26 @@ from M02_MasterTemplete import M02_Template_Layers as layers
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
# 지금 다루는 층 폴더 — 층 라우터가 `at(층 폴더)` 로 바꿈 · 안 바꾸면 시스템 층(`FOLDER`).
# 회사 · 개인 층도 시스템과 같은 모양(`table/구조물집계표.json` · 조합 장부 ·
# structure/area/material · 자재조합.json — PLAN 21-1)이라 아래 함수는 그대로 씀
_ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None)
def root() -> Path:
return _ROOT.get() or FOLDER
@contextmanager
def at(folder: str | Path) -> Iterator[Path]:
"""이 안에서 부르는 저장소 함수는 `folder` 층을 봄(요청 하나 · 스레드 하나 안에서만)."""
token = _ROOT.set(Path(folder))
try:
yield Path(folder)
finally:
_ROOT.reset(token)
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
# (둘 다 스프레드시트 통합문서)
@@ -75,7 +98,7 @@ def _path(kind: str, name: str) -> Path:
or _BAD_NAME.search(name)
):
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
return FOLDER / kind / f"{name}.json"
return root() / kind / f"{name}.json"
def _info(kind: str, path: Path) -> dict[str, Any]:
@@ -91,7 +114,7 @@ def list_all() -> list[dict[str, Any]]:
rows = [
_info(kind, p)
for kind in KINDS
for p in sorted((FOLDER / kind).glob("*.json"))
for p in sorted((root() / kind).glob("*.json"))
if not p.name.startswith(".")
]
return rows
@@ -162,7 +185,7 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
def _docs(kind: str) -> list[tuple[str, Any]]:
folder = FOLDER / kind
folder = root() / kind
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
@@ -260,7 +283,7 @@ def _new_code(column: str) -> str:
def _retired() -> list[str]:
"""지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
path = FOLDER / COMBO_FILE
path = root() / COMBO_FILE
return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else []
@@ -318,7 +341,7 @@ def _take_origins(doc: Any) -> dict[str, str]:
source = col.pop("본뜬열", None)
if not source or source not in by_id or not col.get("id"):
continue
if (FOLDER / "structure" / f"{col['id']}.json").is_file():
if (root() / "structure" / f"{col['id']}.json").is_file():
continue
out[col["id"]] = source
for field in ("일위대가", "자재조합"):
@@ -360,14 +383,14 @@ def prune_removed_structures() -> list[str]:
codes: set[str] = set()
with _LOCK:
for kind, key, _, _ in PARTS:
for path in sorted((FOLDER / kind).glob("[!.]*.json")):
for path in sorted((root() / kind).glob("[!.]*.json")):
if path.stem not in keep:
code = _code(json.loads(path.read_bytes()), key)
codes.update([code] if code else [])
path.unlink()
gone.append(f"{kind}/{path.stem}")
if codes: # 지운 번호는 장부에 남겨 새 열이 다시 안 받게
book = FOLDER / COMBO_FILE
book = root() / COMBO_FILE
doc = json.loads(book.read_bytes()) if book.is_file() else {}
doc["지운번호"] = sorted(codes | set(doc.get("지운번호", [])))
atomic_write_json(book, doc)
@@ -377,7 +400,7 @@ def prune_removed_structures() -> list[str]:
def _move_basis() -> list[str]:
"""12장 산출근거 `basis/` → 면적산출 `area/` (내용 · 알약 연결 그대로 · 표번 칸 → 면적번 ·
시트 이름 산출근거 → 면적산출) · 옮긴 뒤 빈 `basis/` 폴더를 지움."""
old = FOLDER / "basis"
old = root() / "basis"
if not old.is_dir():
return []
moved: list[str] = []
@@ -478,7 +501,7 @@ def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[s
def sync_combos() -> dict[str, Any]:
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
path = FOLDER / COMBO_FILE
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("줄", [])}
@@ -502,5 +525,5 @@ def sync_combos() -> dict[str, Any]:
def read_combos() -> dict[str, Any]:
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
path = FOLDER / COMBO_FILE
path = root() / COMBO_FILE
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
@@ -37,6 +37,7 @@ import {
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
const KINDS: { kind: Kind; label: () => string }[] = [
@@ -126,13 +127,20 @@ export function buildSide(opt: SideOptions): SideHandle {
confirmLeave: opt.confirmLeave,
show: opt.showView,
close: () => opt.onOpen(null),
onOpen: () => paint(),
onOpen: () => (options.clear(), paint()),
});
const matsetSection = section(matset.title, matset.body, ...matset.actions);
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (matsetSection.classList.contains("is-collapsed")) void matset.refresh();
});
// 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크)
const options = buildOptionsSection({
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (matset.clear(), paint()),
});
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
const summary = section(
L("M02_KindTable"),
@@ -147,7 +155,10 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
summary,
...(layer === "system"
? [section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))]
? [
section(options.title, options.body),
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
]
: []),
],
});
@@ -223,6 +234,7 @@ export function buildSide(opt: SideOptions): SideHandle {
paintColumns();
paintButtons();
if (opt.getOpen()) matset.clear();
if (opt.getOpen()) options.clear();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
@@ -145,3 +145,73 @@
padding: var(--spacing-24);
color: var(--color-text);
}
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
.m02-opts {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 64rem;
font-size: var(--text-body-sm);
}
.m02-opts__panes {
display: grid;
grid-template-columns: minmax(14rem, 20rem) 1fr;
gap: var(--spacing-16);
align-items: start;
}
.m02-opts__kinds,
.m02-opts__list {
max-height: calc(100vh - 20rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-opts__group {
margin: var(--spacing-8) 0 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-opts__kind {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-opts__kind:hover {
background: var(--color-mist-violet);
}
.m02-opts__kind.is-active {
border-color: var(--color-accent);
background: var(--color-mist-violet);
}
.m02-opts__option {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
padding: var(--spacing-4) 0;
cursor: pointer;
}
.m02-opts__key {
color: var(--color-text-muted);
font-size: 11px;
}
+25
View File
@@ -0,0 +1,25 @@
/* =============================================================================
* common_util_station.ts
* 측점 화면 표기 한 벌 — B05 · B06 목록 · 폼 · 툴팁 · 종단 표시가 모두 이것을 씀
* (`common_util_svg` 가 다시 내보냄 · B05 는 `formatStation` 이름으로 가져다 씀). 가져오는 것이 없는
* 순수 함수라 서버 Node · 시험 헬퍼에서도 그대로 돎.
* ========================================================================== */
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`).
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return stationPartsLabel(stationNumber, remainder);
}
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */
export function stationPartsLabel(stationNumber: number, remainder: number): string {
return `${stationNumber}+${Math.round(remainder)}`;
}
+1 -12
View File
@@ -49,15 +49,4 @@ export function inferStationInterval(stations: Array<{ chainage_m: number }>): n
);
}
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}
export { stationLabel } from "./common_util_station";
+2
View File
@@ -63,6 +63,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
@@ -638,6 +639,7 @@ app.include_router(m01_master_data_router, dependencies=system_admin_only)
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
app.include_router(m02_master_template_router, dependencies=system_admin_only)
app.include_router(m02_layers_router, dependencies=protected)
app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
@@ -0,0 +1 @@
{}
@@ -101,4 +101,16 @@ out.moved = store.edits();
store.resetStation(200);
out.deleted = store.edits();
// 화면 표기 한 벌 — 거리는 정수로 보이기만(PLAN 20장 · 119.8 → 5+20)
const { stationLabel, stationPartsLabel } = loadTs(
path.join(ROOT, "common_util", "common_util_station.ts"),
);
out.labels = [
stationLabel(119.8, INTERVAL),
stationLabel(120, INTERVAL),
stationLabel(84.3, INTERVAL),
stationLabel(123.37, INTERVAL),
stationPartsLabel(5, 19.8),
];
process.stdout.write(JSON.stringify(out));
@@ -22,6 +22,8 @@ from typing import Any
import pytest
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
ROOT = Path(__file__).resolve().parents[2]
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
@@ -425,6 +427,10 @@ class Flow:
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
if not any(abs(n - c) < 0.005 for n in near):
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
# 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
name = cross_filename(c)
if not (root / "B06_Section" / "cross_sections" / name).is_file():
d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
self._check_designs()
def _check_designs(self) -> None:
@@ -192,7 +192,7 @@ def test_matching_signature_loads_points(tmp_path):
]
# ── 횡단 파일명 충돌 — 한 정수 미터에 측점 하나 · 관 · 구조물 자체 측점 우선(PLAN 19장) ──
# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ──────────────
def _stations(extra_stations):
@@ -204,31 +204,32 @@ def _stations(extra_stations):
return result["longitudinal"]["stations"]
def test_extra_station_in_grid_meter_replaces_grid_station():
def test_extra_station_in_grid_meter_keeps_both_with_own_names():
stations = _stations(((100.4, "X"),))
chainages = [station["chainage_m"] for station in stations]
# 관 · 구조물 자체 측점 그대로 — 같은 정수 미터 격자 100 은 빠져 파일(cross_00100m)은 한 장
assert 100.4 in chainages and 100.0 not in chainages
assert 100.4 in chainages and 100.0 in chainages
labelled = [station for station in stations if station.get("structure") == "X"]
assert [(station["chainage_m"], station["kind"]) for station in labelled] == [
(100.4, "irregular")
]
assert cross_filename(100.4) == "cross_00100.40m.json"
assert len({cross_filename(c) for c in chainages}) == len(chainages)
def test_extra_station_at_route_end_keeps_end_station():
stations = _stations(((0.3, "S"), (349.8, "E")))
chainages = [station["chainage_m"] for station in stations]
# 시 · 종점은 격자 그대로 두고 라벨만 얹음
assert chainages[0] == 0.0 and chainages[-1] == 350.0
assert 0.3 not in chainages and 349.8 not in chainages
assert stations[0].get("structure") == "S" and stations[-1].get("structure") == "E"
def test_extra_station_on_grid_within_5mm_labels_grid_station():
stations = _stations(((100.003, "X"), (0.3, "S")))
labelled = {station["chainage_m"]: station for station in stations if station.get("structure")}
assert sorted(labelled) == [0.3, 100.0]
assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular"
def test_two_extras_in_same_meter_bucket_keep_one():
def test_two_extras_in_same_meter_both_kept():
stations = _stations(((80.2, "A"), (80.4, "B")))
assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.2]
assert 80.0 not in [station["chainage_m"] for station in stations]
assert [station["chainage_m"] for station in stations if station.get("structure")] == [
80.2,
80.4,
]
assert 80.0 in [station["chainage_m"] for station in stations]
def test_live_extras_stay_irregular_and_filenames_stay_unique():
@@ -287,7 +288,7 @@ def test_run_section_generation_derives_stations_from_truth_files(project):
]
cross_dir = project / "B06_Section" / "cross_sections"
for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"):
assert (cross_dir / f"{name}.json").is_file()
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
assert len(result["cross_sections"]) == len(stations)
+30 -3
View File
@@ -37,6 +37,10 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results):
assert results["r119"] == ["5", "19.8", 119.8]
def test_화면_표기는_거리를_정수로(results):
assert results["labels"] == ["5+20", "6+0", "4+4", "6+3", "5+20"]
def test_칸을_고치면_친_값(results):
assert results["edited"] == pytest.approx(125.2)
assert results["normalized"] == ["2", "10.0", 50]
@@ -96,8 +100,31 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path):
SectionGenerationOptions(extra_stations=extras),
)
chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]]
# 횡단 측점도 자체 측점 119.8(같은 정수 미터 격자 120 대신 · 파일 이름 cross_00120m 한 장)
assert 119.8 in chainages and 120.0 not in chainages
assert cross_filename(119.8) == cross_filename(120.0)
# 소수 측점 횡단을 따로 둠(PLAN 20장) — 119.8 · 격자 120 둘 다 · 이름도 따로
assert 119.8 in chainages and 120.0 in chainages
assert (cross_filename(119.8), cross_filename(120.0)) == (
"cross_00119.80m.json",
"cross_00120m.json",
)
_, again = load_structures(str(tmp_path))
assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만"
def test_옛_프로젝트_정수_이름_횡단은_찾고_안_지움(tmp_path):
"""소수 측점이 정수 m 이름으로 저장된 옛 프로젝트 — 열 때 지우지 않고 그 파일을 씀."""
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file, prune_stale_cross_files
(tmp_path / "cross_00084m.json").write_text("{}", encoding="utf-8") # 옛 84.3
(tmp_path / "cross_00100m.json").write_text("{}", encoding="utf-8")
(tmp_path / "cross_00119.80m.json").write_text("{}", encoding="utf-8") # 새 이름
(tmp_path / "cross_00500m.json").write_text("{}", encoding="utf-8") # 잔재
stations = [{"chainage_m": 84.3}, {"chainage_m": 100.0}, {"chainage_m": 119.8}]
valid = prune_stale_cross_files(tmp_path, stations)
assert sorted(p.name for p in tmp_path.iterdir()) == [
"cross_00084m.json",
"cross_00100m.json",
"cross_00119.80m.json",
]
assert valid == {"cross_00084m.json", "cross_00100m.json", "cross_00119.80m.json"}
assert find_cross_file(tmp_path, 84.3).name == "cross_00084m.json"
assert find_cross_file(tmp_path, 119.8).name == "cross_00119.80m.json"
@@ -34,3 +34,9 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch):
def test_모든_수정에_행이_있으면_안_부름(monkeypatch):
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == []
assert _calls(monkeypatch, [], [120.0]) == []
def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch):
# 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움
assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT]
assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == []
+172
View File
@@ -0,0 +1,172 @@
"""M02 층 집계표(PLAN 21-2) — 층별 읽기 · 쓰기 · 권한 · 첫 진입 뼈대 · 항목 목록 · 항목 복사 ·
시스템 회귀.
임시 storage(회사 · 개인 층) + 견본 시스템 집계표(`write_sample_table`)로만 봄 —
실제 양식은 안 읽음(시스템 층 두 자리 `store.FOLDER` · `layers.SYSTEM_ROOT` 모두 사본).
자재 코드는 실제 M01 마스터에 있는 MT 키를 씀(자재 조합 저장이 마스터 대조를 함).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
TABLE = "table/구조물집계표"
def _material() -> dict[str, Any]:
for data in m01.loaded()[0].values():
for row in data.get("줄") or []:
if str(row.get("키", "")).startswith("MT"):
return {"코드": row["키"], "이름": row.get("이름"), "규격": None, "단위": None}
raise AssertionError("마스터에 자재 키가 없음")
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
store.renumber()
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(layers_router.router)
app.include_router(items_router.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "storage": storage, "system": system}
def _as(world: dict, role: str = "USER", user: int = 42, company: int | None = 7) -> TestClient:
world["session"].update(role=role, user_id=user, company_id=company)
return world["client"]
def _add_column(client: TestClient, layer: str, col: dict[str, Any]) -> None:
url = f"/api/m02/layers/{layer}/templates/{TABLE}"
got = client.get(url).json()
got["문서"]["열"].append(col)
put = client.put(url, json={"판": got["판"], "문서": got["문서"]})
assert put.status_code == 200, put.text
COL = {"id": "cw", "머리": ["돌기슭막이", "찰쌓기", "회사형", None], "단위": "m", "꼴": "수"}
def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
client = _as(world)
rows = client.get("/api/m02/items").json()
ss = store.read_combos()["줄"]
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
assert set(rows[0]) == {"key", *items_router.items_store.ITEM_FIELDS}
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
# 시스템 층 쓰기는 SYSTEM_ADMIN 만
assert client.post("/api/m02/layers/system/structures/rv").status_code == 403
url = f"/api/m02/layers/system/templates/{TABLE}"
got = client.get(url).json()
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 403
assert client.delete(f"{url}?판={got['판']}").status_code == 403
admin = _as(world, "SYSTEM_ADMIN")
assert admin.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
assert admin.delete(f"{url}?판={got['판']}").status_code == 409 # 쓰는 양식
def test_회사_층_첫_진입_뼈대와_쓰기_권한(world):
client = _as(world)
assert client.get("/api/m02/items?layer=company").json() == []
root = world["storage"] / "7" / "templates"
table = json.loads((root / "table/구조물집계표.json").read_text("utf-8"))
assert (
table["열"] == []
and table["층"]
== json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))["층"]
)
assert json.loads((root / "구조물집계표조합.json").read_text("utf-8"))["다음"] == 1
assert json.loads((root / "자재조합.json").read_text("utf-8"))["다음"] == 1
url = f"/api/m02/layers/company/templates/{TABLE}"
got = client.get(url).json()
got["문서"]["열"].append(COL)
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 403
_add_column(_as(world, "ADMIN"), "company", COL)
items = _as(world).get("/api/m02/items?layer=company").json() # 같은 회사 일반 사용자
assert [(r["key"], r["열"]) for r in items] == [("SS000001", "cw")]
assert items[0]["도번"].startswith("SD") and (root / "structure/cw.json").is_file()
assert client.get("/api/m02/items?layer=company&owner=9").status_code == 403 # 남의 회사
# 일위대가(UA) 갈라 내기는 M01 시스템 마스터에 쓰므로 회사 관리자도 못 함
body = {"본뜬키": "UA000001", "담은로직": [], "이름": "x"}
admin = _as(world, "ADMIN")
assert admin.post("/api/m02/layers/company/structures/cw/combo", json=body).status_code == 403
assert admin.delete(f"{url}?판={client.get(url).json()['판']}").status_code == 409 # 쓰는 양식
def test_개인_층은_본인만_회사_없으면_409(world):
client = _as(world)
_add_column(client, "personal", {**COL, "id": "p1"})
assert [r["열"] for r in client.get("/api/m02/items?layer=personal").json()] == ["p1"]
assert (world["storage"] / "7" / "42" / "templates" / "area" / "p1.json").is_file()
other = client.get("/api/m02/items?layer=personal&owner=7/43")
assert other.status_code == 403
lonely = _as(world, company=None)
groups = lonely.get("/api/m02/items?all=1").json()
assert [(g["layer"], g["items"]) for g in groups][1:] == [("company", []), ("personal", [])]
assert len(groups[0]["items"]) == len(store.read_combos()["줄"]) # 마스터는 보임
made = lonely.post(
"/api/m02/layers/personal/material-combos", json={"줄": {"이름": "a", "담은자재": []}}
)
assert made.status_code == 409 and "회사부터" in made.json()["detail"]
def test_회사_항목을_개인으로_복사(world):
admin = _as(world, "ADMIN")
_add_column(admin, "company", COL)
base = "/api/m02/layers/company"
drawing = admin.get(f"{base}/templates/structure/cw").json()
doc = {**drawing["문서"], "도면": {**drawing["문서"]["도면"], "entities": [{"type": "line"}]}}
admin.put(f"{base}/templates/structure/cw", json={"판": drawing["판"], "문서": doc})
판 = admin.get(f"{base}/material-combos").json()["판"]
row = {"이름": "회사 자재", "담은자재": [_material()]}
um = admin.post(f"{base}/material-combos", json={"판": 판, "줄": row}).json()["키"]
admin.post(f"{base}/structures/cw/use-combo", json={"종류": "자재조합", "키": um})
ss = admin.get("/api/m02/items?layer=company").json()[0]["key"]
user = _as(world)
body = {"from_layer": "company", "from_owner": "7", "key": ss}
got = user.post("/api/m02/layers/personal/items/copy", json=body)
assert got.status_code == 200, got.text
made = got.json()
assert made["열"] == "cw" and made["key"] == "SS000001"
mine = "/api/m02/layers/personal"
copy = user.get(f"{mine}/templates/structure/cw").json()["문서"]
assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw"
col = next(c for c in user.get(f"{mine}/templates/{TABLE}").json()["문서"]["열"])
assert col["본뜬"] == {"layer": "company", "owner": "7", "key": ss}
mat = user.get(f"{mine}/material-combos/{col['자재조합']}").json()
assert mat["이름"] == "회사 자재" and mat["담은자재"][0]["코드"] == _material()["코드"]
# 또 복사하면 새 열 id · 새 SS 키 · 자재 조합 이름 (2)
again = user.post("/api/m02/layers/personal/items/copy", json=body).json()
assert (again["열"], again["key"]) == ("cw_2", "SS000002")
names = [r["이름"] for r in user.get(f"{mine}/material-combos").json()["줄"]]
assert names == ["회사 자재", "회사 자재 (2)"]
# 마스터 항목도 개인으로 · 없는 키 404
master = {"from_layer": "system", "key": "SS000001"}
assert user.post("/api/m02/layers/personal/items/copy", json=master).status_code == 200
missing = {**body, "key": "SS999999"}
assert user.post("/api/m02/layers/personal/items/copy", json=missing).status_code == 404
@@ -0,0 +1,129 @@
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
못박는 것
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from M02_MasterTemplete import M02_MasterTemplete_Options as opt
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router
ROOT = Path(__file__).resolve().parents[2]
URL = "/api/m02/structure-options"
TYPES = "/api/projects/structure-types"
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(m02_router)
app.include_router(b05_router)
return TestClient(app)
def _pick() -> tuple[str, str, str]:
"""옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키)."""
t = next(t for t in load_structure_types() if len(t.options) >= 2)
return t.type_id, t.options[0].key, t.options[1].key
def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
types = client.get(TYPES).json()["types"]
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
assert (
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
== {}
)
got = client.get(URL).json()
assert got["판"] == "" and got["표시"] == {} # 파일 없음
assert all(all(v.values()) for v in _visible(client).values())
assert client.get(TYPES).json()["schema_version"] == 1
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
tid, off, on = _pick()
before = client.get(TYPES).json()["types"]
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
assert res.status_code == 200, res.text
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
seen = _visible(client)
assert seen[tid][off] is False and seen[tid][on] is True
assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로
after = client.get(TYPES).json()["types"]
strip = lambda ts: [ # noqa: E731
{**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]}
for t in ts
]
assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜
kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid)
assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0]
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
tid, off, _ = _pick()
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
res = client.put(URL, json={"판": v1, "표시": {}})
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
assert all(all(v.values()) for v in _visible(client).values())
def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None:
tid, off, _ = _pick()
for bad in (
{"없는종류": {off: False}},
{tid: {"없는키": False}},
{tid: {off: "no"}},
{tid: [off]},
):
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
assert client.get(URL).json()["판"] == v1
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
assert all(all(v.values()) for v in _visible(client).values())
assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
assert "section(options.title, options.body)" in side
assert (
side.index(" summary,")
< side.index("section(options.title")
< side.index('section(L("M02_KindDrawing")')
)
assert "onOpen: () => (options.clear(), paint())" in side # 자재 조합을 열면 이 줄 고름 지움
assert "onOpen: () => (matset.clear(), paint())" in side # 이 줄을 열면 자재 조합 고름 지움
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
assert (
'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api
)
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
assert 'o.phase === "detail"' in ui # B06 전용 표시
+3 -2
View File
@@ -143,12 +143,13 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
session.update(user_id=43)
theirs = mine + "&user_id=42"
assert client.get(theirs).status_code == 200
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
put = client.put(
f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}&user_id=42",
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀
assert (world["storage"] / "7/43/templates/table/구조물집계표.json").is_file()
assert (world["storage"] / "7/43/templates/table/내표.json").is_file()
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
@@ -0,0 +1,139 @@
/**
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양).
* 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts
*/
import assert from "node:assert/strict";
import {
buildTree,
filterRows,
folderLines,
isCurrent,
pathIds,
rowId,
toSummary,
type TreeLabels,
} from "../../../ui_template/ui_template_item_picker_data.ts";
import { fakeCopy, fakeItems } from "../../../ui_template/ui_template_item_picker_fake.ts";
const labels: TreeLabels = {
layers: { system: "마스터", company: "회사", personal: "개인" },
unclassified: "분류 없음",
};
const rows = fakeItems();
// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎
const tree = buildTree(rows, labels);
assert.deepEqual(
tree.map((g) => [g.label, g.count]),
[
["마스터", 5],
["회사", 2],
["개인", 1],
],
);
const system = tree[0];
assert.deepEqual(
system.children?.map((k) => [k.label, k.count]),
[
["돌기슭막이", 3],
["콘크리트포장", 1],
["관보호공", 1],
],
);
const cat = system.children?.[0].children?.[0];
assert.equal(cat?.label, "메쌓기");
assert.equal(cat?.count, 2);
const leaf = cat?.children?.[0];
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(leaf?.tip, "SS000001"); // 키는 툴팁
assert.equal(leaf?.count, null);
assert.ok(leaf?.row);
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
const company = tree[1];
assert.equal(company.children?.[1].children?.[0].label, "분류 없음");
// 빈 그룹도 한 줄은 남음
const onlySystem = buildTree(
rows.filter((r) => r.layer === "system"),
labels,
);
assert.deepEqual(
onlySystem.map((g) => [g.id, g.count, g.children?.length]),
[
["system", 5, 3],
["company", 0, 0],
["personal", 0, 0],
],
);
// 잎 id 는 층 · 주인 · 키 — 같은 키라도 층이 다르면 다른 줄
const twin = { ...rows[0], layer: "company" as const, owner: "샘플회사" };
assert.notEqual(rowId(rows[0]), rowId(twin));
// 찾기 — 이름 · 키 · 연결 이름 · 여러 낱말은 모두 들어 있어야 함 · 빈 글은 전부
assert.equal(filterRows(rows, "").length, rows.length);
assert.equal(filterRows(rows, " ").length, rows.length);
assert.deepEqual(
filterRows(rows, "옹벽").map((r) => r.key),
["SS000102"],
);
assert.deepEqual(
filterRows(rows, "ss000004").map((r) => r.key),
["SS000004"],
);
assert.deepEqual(
filterRows(rows, "돌기슭막이 개인").map((r) => r.key),
["SS000201"],
);
assert.equal(filterRows(rows, "없는이름").length, 0);
// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인
assert.deepEqual(toSummary(rows[0]), {
layer: "system",
owner: null,
key: "SS000001",
name: "돌기슭막이 메쌓기 1.2m 기초유",
});
const mine = rows.find((r) => r.layer === "personal");
assert.ok(mine);
assert.deepEqual(toSummary(mine), {
layer: "personal",
owner: "샘플회사/사용자",
key: "SS000201",
name: "돌기슭막이 메쌓기 1.2m(개인)",
});
// layer 없는 줄(옛 서버)은 마스터로 봄
assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system");
// 지금 지정된 항목인지
assert.ok(isCurrent(toSummary(mine), mine));
assert.ok(!isCurrent(toSummary(mine), rows[0]));
assert.ok(!isCurrent(null, rows[0]));
// 처음 펼칠 자리
assert.deepEqual(pathIds(rows[0], labels), [
"system",
"system|돌기슭막이",
"system|돌기슭막이|메쌓기",
]);
// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글
const lines = folderLines(rows[0]);
assert.deepEqual(
lines.map((l) => l.kind),
["drawing", "area", "material", "combo", "unitPrice"],
);
assert.equal(lines[0].text, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(lines[0].tip, "SD000001");
assert.equal(folderLines({ key: "SS1", 열: "빈 항목" })[3].text, "");
// 가짜 복사 — 새 개인 줄이 생기고 키가 겹치지 않음
const before = fakeItems();
const copied = fakeCopy(before[0]);
const after = fakeItems();
assert.equal(after.length, before.length + 1);
assert.equal(copied.layer, "personal");
assert.equal(new Set(after.map(rowId)).size, after.length);
console.log("항목 고르기 자료 시험 통과");
+101
View File
@@ -0,0 +1,101 @@
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
.ui-item-picker {
width: min(900px, 94vw);
}
.ui-item-picker .ui-modal__body {
height: min(60vh, 520px);
}
.ui-item-picker__cols {
display: grid;
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
gap: var(--spacing-12);
flex: 1;
min-height: 0;
}
.ui-item-picker__left {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
min-width: 0;
}
.ui-item-picker__tree {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-height: 0;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: auto;
}
.ui-item-picker__detail {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
min-height: 0;
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: auto;
}
.ui-item-picker__name {
margin: 0;
font-size: var(--text-body);
color: var(--color-text-heading);
overflow-wrap: anywhere;
}
.ui-item-picker__where,
.ui-item-picker__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-item-picker__hint {
padding: var(--spacing-8);
}
.ui-item-picker__folder {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0 0 0 var(--spacing-12);
border-left: 2px solid var(--color-border);
list-style: none;
}
.ui-item-picker__folder li {
display: flex;
gap: var(--spacing-8);
align-items: baseline;
min-width: 0;
color: var(--color-text-body);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.ui-item-picker__folder-label {
flex: 0 0 5.5em;
color: var(--color-text-muted);
}
.ui-item-picker__none {
color: var(--color-text-muted);
}
.ui-item-picker__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
+289
View File
@@ -0,0 +1,289 @@
/* =============================================================================
* ui_template_item_picker.ts
* 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름.
* 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0).
*
* const result = await openItemPicker({ current, canCopy });
* result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel"
* ========================================================================== */
import "./ui_template_item_picker.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, createInputField, el, showToast } from "./ui_template_elements";
import { fakeCopy, fakeItems } from "./ui_template_item_picker_fake";
import {
buildTree,
filterRows,
folderLines,
isCurrent,
layerOf,
ownerOf,
pathIds,
rowId,
toSummary,
type FolderKind,
type PickNode,
type PickerRow,
type SummaryItem,
type TreeLabels,
} from "./ui_template_item_picker_data";
import { t } from "./ui_template_locale";
import { openModal } from "./ui_template_modal";
import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree";
export type { SummaryItem } from "./ui_template_item_picker_data";
export interface ItemPickerOptions {
/** 지금 지정된 항목 — 없으면 null */
current: SummaryItem | null;
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
canCopy: boolean;
}
export type ItemPickerResult =
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
const MANAGE_HASH = "#/m02-master-template?layer=personal";
const FOLDER_LABEL: Record<FolderKind, () => string> = {
drawing: () => t("M02_PickerDrawing"),
area: () => t("M02_PickerArea"),
material: () => t("M02_PickerMaterial"),
combo: () => t("M02_PickerCombo"),
unitPrice: () => t("M02_PickerUnitPrice"),
};
const labels = (): TreeLabels => ({
layers: {
system: t("M02_PickerSystem"),
company: t("M02_PickerCompany"),
personal: t("M02_PickerPersonal"),
},
unclassified: t("M02_PickerNoClass"),
});
/** 서버가 막은 까닭 그대로 */
function reasonOf(body: unknown, status: number): string {
const detail = (body as { detail?: unknown } | null)?.detail;
return typeof detail === "string" && detail ? detail : `HTTP ${status}`;
}
/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */
async function loadRows(): Promise<PickerRow[]> {
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
if (response.status === 404 || response.status === 405) return fakeItems();
const body = (await response.json().catch(() => null)) as
PickerRow[] | { items?: PickerRow[]; detail?: unknown } | null;
if (!response.ok) throw new Error(reasonOf(body, response.status));
return Array.isArray(body) ? body : (body?.items ?? []);
}
/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */
async function copyToPersonal(row: PickerRow): Promise<string | null> {
const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }),
});
if (response.status === 404 || response.status === 405) return fakeCopy(row).key;
const body = (await response.json().catch(() => null)) as { key?: string } | null;
if (!response.ok) throw new Error(reasonOf(body, response.status));
return body?.key ?? null;
}
const message = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResult> {
return new Promise((resolve) => {
let rows: PickerRow[] = [];
let chosen: PickerRow | null = null;
/** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */
const opened = new Set<string>();
const leafButtons = new Map<string, HTMLElement>();
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
const treeHost = el("div", { className: "ui-item-picker__tree" });
const detail = el("div", { className: "ui-item-picker__detail" });
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" });
let close = (): void => {};
const finish = (result: ItemPickerResult): void => {
resolve(result);
close();
};
const hint = (text: string): HTMLElement =>
el("p", { className: "ui-item-picker__hint", text });
const paintDetail = (): void => {
if (!chosen) {
detail.replaceChildren(hint(t("M02_PickerHint")));
} else {
const where = [labels().layers[layerOf(chosen)], chosen.종류, chosen.분류1]
.filter(Boolean)
.join(" › ");
const small = (text: string): HTMLElement =>
el("p", { className: "ui-item-picker__where", text });
detail.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
text: chosen.열,
attrs: { title: chosen.key },
}),
small(where),
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
el("ul", {
className: "ui-item-picker__folder",
children: folderLines(chosen).map((line) =>
el("li", {
attrs: line.tip ? { title: line.tip } : {},
children: [
el("span", {
className: "ui-item-picker__folder-label",
text: FOLDER_LABEL[line.kind](),
}),
el("span", {
className: line.text ? "" : "ui-item-picker__none",
text: line.text || t("M02_PickerNone"),
}),
],
}),
),
}),
);
}
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy);
manageButton.hidden = !(chosen && layer === "personal");
};
const choose = (row: PickerRow): void => {
chosen = row;
for (const [id, button] of leafButtons)
button.classList.toggle("is-active", id === rowId(row));
paintDetail();
};
const toTree = (node: PickNode, searching: boolean): TreeNode => ({
id: node.id,
label: node.label,
count: node.count,
tip: node.tip,
open: searching || opened.has(node.id),
run: node.row ? () => choose(node.row as PickerRow) : undefined,
children: node.children?.map((child) => toTree(child, searching)),
});
const paintTree = (): void => {
leafButtons.clear();
const text = search.input.value;
const searching = text.trim() !== "";
const groups = buildTree(filterRows(rows, text), labels())
// 찾는 중에는 결과가 없는 그룹을 감춤
.filter((group) => !searching || (group.count ?? 0) > 0)
.map((group) => toTree(group, searching));
if (!groups.length) {
treeHost.replaceChildren(hint(t("M02_PickerNoMatch")));
return;
}
treeHost.replaceChildren(
...renderTree(groups, (node, caret, depth, onClick) => {
const button = createTreeRow(node, caret, depth, () => {
onClick();
if (!node.children?.length) return;
if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id);
else opened.delete(node.id);
});
if (node.run) {
leafButtons.set(node.id, button);
if (chosen && node.id === rowId(chosen)) button.classList.add("is-active");
}
return button;
}),
);
};
const load = async (pickKey: string | null): Promise<void> => {
treeHost.replaceChildren(hint(t("M02_PickerLoading")));
try {
rows = await loadRows();
} catch (error) {
treeHost.replaceChildren(
hint(t("M02_PickerLoadFailed").replace("{value}", message(error))),
);
return;
}
const start = pickKey
? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey)
: rows.find((row) => isCurrent(opts.current, row));
if (start) {
for (const id of pathIds(start, labels())) opened.add(id);
chosen = start;
} else {
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
}
paintTree();
paintDetail();
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
};
search.input.addEventListener("input", paintTree);
applyButton.addEventListener(
"click",
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
);
clearButton.addEventListener("click", () => finish({ action: "clear" }));
manageButton.addEventListener("click", () => {
finish({ action: "cancel" });
location.hash = MANAGE_HASH;
});
copyButton.addEventListener("click", async () => {
if (!chosen) return;
copyButton.disabled = true;
try {
const key = await copyToPersonal(chosen);
showToast(t("M02_PickerCopied"), "success");
await load(key);
} catch (error) {
showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error");
} finally {
copyButton.disabled = false;
}
});
close = openModal({
title: t("M02_PickerTitle"),
closeLabel: t("M02_Close"),
dialogClass: "ui-item-picker",
// 닫기 단추 · Esc · 바깥 누르기 = 취소
beforeClose: () => {
resolve({ action: "cancel" });
return true;
},
mount: (body) => {
body.append(
el("div", {
className: "ui-item-picker__cols",
children: [
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
detail,
],
}),
el("div", {
className: "ui-item-picker__actions",
children: [copyButton, manageButton, clearButton, applyButton],
}),
);
paintDetail();
void load(null);
},
}).close;
});
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* ui_template_item_picker_data.ts
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
* ========================================================================== */
export type PickerLayer = "system" | "company" | "personal";
/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */
export interface SummaryItem {
layer: PickerLayer;
owner: string | null;
key: string;
name: string;
}
/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */
export interface PickerRow {
key: string;
열: string;
종류?: string;
분류1?: string;
분류2?: string;
분류3?: string;
단위?: string;
일위대가?: string;
자재조합?: string;
도번?: string;
면적번?: string;
재료번?: string;
layer?: PickerLayer;
owner?: string | null;
}
/** 그룹 차례 = 마스터 · 회사 · 개인 */
export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"];
export interface PickNode {
id: string;
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
label: string;
/** 마우스를 올렸을 때 — 잎은 키 */
tip?: string;
/** 아래 잎 수 — 잎은 null */
count: number | null;
children?: PickNode[];
/** 잎이면 그 항목 */
row?: PickerRow;
}
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
export const ownerOf = (row: PickerRow): string | null =>
layerOf(row) === "system" ? null : (row.owner ?? null);
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: row.열 };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
return (
!!current &&
current.layer === layerOf(row) &&
(current.owner ?? null) === ownerOf(row) &&
current.key === row.key
);
}
/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */
export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return rows;
return rows.filter((row) => {
const hay = [
row.열,
row.종류,
row.분류1,
row.분류2,
row.분류3,
row.key,
row.일위대가,
row.자재조합,
]
.join(" ")
.toLowerCase();
return words.every((word) => hay.includes(word));
});
}
export interface TreeLabels {
layers: Record<PickerLayer, string>;
/** 종류 · 분류1 이 비었을 때 */
unclassified: string;
}
/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
return PICKER_LAYERS.map((layer) => {
const mine = rows.filter((row) => layerOf(row) === layer);
const kinds = new Map<string, Map<string, PickerRow[]>>();
for (const row of mine) {
const kind = row.종류 || labels.unclassified;
const cat = row.분류1 || labels.unclassified;
const cats = kinds.get(kind) ?? new Map<string, PickerRow[]>();
cats.set(cat, [...(cats.get(cat) ?? []), row]);
kinds.set(kind, cats);
}
const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => {
const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({
id: `${layer}|${kind}|${cat}`,
label: cat,
count: leaves.length,
children: leaves.map((row) => ({
id: rowId(row),
label: row.열,
tip: row.key,
count: null,
row,
})),
}));
return {
id: `${layer}|${kind}`,
label: kind,
count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0),
children: catNodes,
};
});
return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes };
});
}
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
export function pathIds(row: PickerRow, labels: TreeLabels): string[] {
const layer = layerOf(row);
const kind = row.종류 || labels.unclassified;
const cat = row.분류1 || labels.unclassified;
return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`];
}
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
export interface FolderLine {
kind: FolderKind;
/** 보이는 이름 */
text: string;
/** 마우스를 올렸을 때 — 키 · 번호 */
tip: string;
}
/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */
export function folderLines(row: PickerRow): FolderLine[] {
return [
{ kind: "drawing", text: row.열, tip: row.도번 ?? "" },
{ kind: "area", text: row.열, tip: row.면적번 ?? "" },
{ kind: "material", text: row.열, tip: row.재료번 ?? "" },
{ kind: "combo", text: row.자재조합 ?? "", tip: row.자재조합 ?? "" },
{ kind: "unitPrice", text: row.일위대가 ?? "", tip: row.일위대가 ?? "" },
];
}
@@ -0,0 +1,60 @@
/* =============================================================================
* ui_template_item_picker_fake.ts
* 항목 목록 서버(`GET /api/m02/items` · 복사 `POST /api/m02/layers/personal/items/copy`, 21-2)가
* 아직 없을 때만 쓰는 가짜 응답 — 21-1 모양 그대로. 실서버가 오면 이 파일과 부르는 자리를 지움.
* ========================================================================== */
import type { PickerRow } from "./ui_template_item_picker_data";
const row = (
key: string,
열: string,
종류: string,
분류1: string,
layer: PickerRow["layer"],
extra: Partial<PickerRow> = {},
): PickerRow => ({
key,
열,
종류,
분류1,
분류2: "",
분류3: "",
단위: "m",
일위대가: `${열} 일위대가`,
자재조합: `${열} 자재`,
도번: `SD${key.slice(2)}`,
면적번: `SA${key.slice(2)}`,
재료번: `SM${key.slice(2)}`,
layer,
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
...extra,
});
let personalSeq = 900;
const memory: PickerRow[] = [
row("SS000001", "돌기슭막이 메쌓기 1.2m 기초유", "돌기슭막이", "메쌓기", "system"),
row("SS000002", "돌기슭막이 메쌓기 1.5m 기초유", "돌기슭막이", "메쌓기", "system"),
row("SS000003", "돌기슭막이 찰쌓기 1.2m", "돌기슭막이", "찰쌓기", "system"),
row("SS000004", "콘크리트포장 T=20cm B=3m", "콘크리트포장", "일반", "system", { 단위: "㎡" }),
row("SS000005", "관보호공 콘크리트 유입", "관보호공", "콘크리트", "system", { 단위: "개소" }),
row("SS000101", "돌기슭막이 메쌓기 1.2m(회사)", "돌기슭막이", "메쌓기", "company"),
row("SS000102", "옹벽 H=2m(회사)", "옹벽", "", "company"),
row("SS000201", "돌기슭막이 메쌓기 1.2m(개인)", "돌기슭막이", "메쌓기", "personal"),
];
export const fakeItems = (): PickerRow[] => memory.map((item) => ({ ...item }));
/** 복사 — 새 개인 항목을 맨 뒤에 더하고 그 줄을 돌려줌(서버는 새 SS 키를 돌려줄 것) */
export function fakeCopy(from: PickerRow): PickerRow {
personalSeq += 1;
const copy: PickerRow = {
...from,
key: `SS000${personalSeq}`,
layer: "personal",
owner: "샘플회사/사용자",
};
memory.push(copy);
return { ...copy };
}
+23
View File
@@ -74,4 +74,27 @@ export const ui_locales_m2 = {
"Register a company first to manage structure templates",
],
M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"],
M02_PickerTitle: ["항목 고르기", "Pick an item"],
M02_PickerSearch: ["항목 찾기", "Search items"],
M02_PickerSystem: ["마스터", "Master"],
M02_PickerCompany: ["회사", "Company"],
M02_PickerPersonal: ["개인", "Personal"],
M02_PickerNoClass: ["분류 없음", "Unclassified"],
M02_PickerHint: ["왼쪽에서 항목을 고를 것", "Pick an item on the left"],
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
M02_PickerLoading: ["읽는 중…", "Loading…"],
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
M02_PickerDrawing: ["도면", "Drawing"],
M02_PickerArea: ["면적산출", "Area takeoff"],
M02_PickerMaterial: ["재료산출", "Material takeoff"],
M02_PickerCombo: ["자재조합", "Material combo"],
M02_PickerUnitPrice: ["일위대가", "Unit price"],
M02_PickerUnit: ["단위", "Unit"],
M02_PickerNone: ["없음", "None"],
M02_PickerApply: ["적용", "Apply"],
M02_PickerClear: ["해제", "Clear"],
M02_PickerCopy: ["개인용으로 복사", "Copy to personal"],
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
} as const;
+23
View File
@@ -0,0 +1,23 @@
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */
.ui-tree__kids {
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-tree__branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-tree__caret {
display: inline-block;
width: var(--spacing-12);
color: var(--color-text-muted);
}
.ui-tree__count {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
+76
View File
@@ -0,0 +1,76 @@
/* =============================================================================
* ui_template_tree.ts
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
* ========================================================================== */
import "./ui_template_tree.css";
import { el } from "./ui_template_elements";
export interface TreeNode {
id: string;
label: string;
/** 줄 수 — null 이면 안 보임 */
count: number | null;
/** 눌렀을 때 — 없으면 접기/펴기만 */
run?: () => void;
children?: TreeNode[];
/** 처음부터 펴짐 */
open?: boolean;
/** 마우스를 올렸을 때 글(기본 줄) */
tip?: string;
}
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
export type MakeRow = (
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
) => HTMLElement;
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
return nodes.map((node) => {
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
const kids = el("div", {
className: "ui-tree__kids",
children: renderTree(node.children, make, depth + 1),
});
kids.hidden = !node.open;
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".ui-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
node.run?.();
});
return el("div", { className: "ui-tree__branch", children: [row, kids] });
});
}
/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
export function createTreeRow(
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
): HTMLButtonElement {
const button = el("button", {
className: "ui-tree__row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("span", {
children: [
el("span", { className: "ui-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
...(node.count === null
? []
: [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]),
],
});
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
if (node.tip) button.title = node.tip;
button.addEventListener("click", onClick);
return button;
}