Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
+22
-2
@@ -12,7 +12,13 @@ import "@ui/ui_template_theme.css";
|
||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||
import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router";
|
||||
import {
|
||||
currentRoute,
|
||||
renderCurrentRoute,
|
||||
ensureInitialHash,
|
||||
setLeaveGuard,
|
||||
takeLeaveGuard,
|
||||
} from "./router";
|
||||
|
||||
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B01_ACCOUNT,
|
||||
@@ -27,6 +33,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B10_PAYMENT,
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.M01_MASTER_DATA,
|
||||
ROUTES.M02_MASTER_TEMPLATE,
|
||||
];
|
||||
|
||||
function bootstrap(): void {
|
||||
@@ -43,14 +50,27 @@ function bootstrap(): void {
|
||||
initThemeAndLang();
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
const renderAll = (): void => {
|
||||
shown = currentRoute();
|
||||
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
|
||||
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
||||
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
||||
};
|
||||
|
||||
// 4. hashchange 1회 구독 (리스너 누적 방지)
|
||||
window.addEventListener("hashchange", renderAll);
|
||||
window.addEventListener("hashchange", () => {
|
||||
const guard = takeLeaveGuard();
|
||||
if (!guard || currentRoute() === shown) return renderAll();
|
||||
void guard().then((ok) => {
|
||||
if (ok) {
|
||||
setLeaveGuard(null);
|
||||
renderAll();
|
||||
} else {
|
||||
history.replaceState(null, "", `#/${shown}`); // 취소 — 머문 채 주소만 되돌림
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 최초 진입
|
||||
ensureInitialHash();
|
||||
|
||||
@@ -61,8 +61,17 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
||||
[ROUTES.M01_MASTER_DATA]: async () =>
|
||||
(await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData,
|
||||
[ROUTES.M02_MASTER_TEMPLATE]: async () =>
|
||||
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
||||
};
|
||||
|
||||
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
||||
let leaveGuard: (() => Promise<boolean>) | null = null;
|
||||
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
||||
leaveGuard = guard;
|
||||
};
|
||||
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||
|
||||
@@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement {
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.M01_MASTER_DATA),
|
||||
}),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_MasterTemplate"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE),
|
||||
}),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,6 +17,9 @@ from common_util.common_util_workflow import load_project_workflow
|
||||
from common_util.common_util_workflow_state import initialize_project_stages
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
from M02_MasterTemplete.M02_Template_Layers import seed_project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_project_storage(company_id: int, user_id: int, project_id: str) -> tuple[str, Path]:
|
||||
@@ -40,6 +44,12 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None:
|
||||
"stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STORAGE_LAYOUT_V2],
|
||||
},
|
||||
)
|
||||
# 마스터 템플릿 — 시스템 양식 전부를 작업본 + `_initial/` 로(회사 공식이 있어도 시스템).
|
||||
# 복사가 실패해도 프로젝트는 만듦 — 읽는 쪽이 시스템 양식으로 떨어짐.
|
||||
try:
|
||||
seed_project(project_root)
|
||||
except OSError:
|
||||
logger.exception("프로젝트 양식 복사 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
async def create_project(
|
||||
|
||||
@@ -175,7 +175,7 @@ export interface FrameTemplateResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
drawing: CadDrawing;
|
||||
/** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */
|
||||
/** 프로젝트 도각이 처음 복사한 도각과 다르면 true. */
|
||||
customized: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */
|
||||
fields?: Record<string, string>;
|
||||
@@ -237,7 +237,7 @@ export async function exportDrawing(
|
||||
};
|
||||
}
|
||||
|
||||
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
|
||||
/** 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다. */
|
||||
export function resetFrameTemplate(projectId: string): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다.
|
||||
|
||||
resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
|
||||
resources/master_template/drawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
|
||||
도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어
|
||||
(b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 —
|
||||
템플릿 쪽을 확대해 콘텐츠를 감싼다.
|
||||
|
||||
A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란,
|
||||
내부 작도 영역 (42, 47) ~ (812, 567).
|
||||
내부 작도 영역 (42, 47) ~ (812, 567). 작도 영역은 양식 칸 `drawing_area` 가 정본이고
|
||||
칸이 없는 양식(옛 회사 도각)은 이 값으로 떨어진다.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
@@ -26,8 +27,19 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
FRAME_LAYER_ID,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
|
||||
from M02_MasterTemplete.M02_Template_Layers import (
|
||||
delete_template,
|
||||
initial_dir,
|
||||
project_dir,
|
||||
reset_project,
|
||||
system_dir,
|
||||
template_path,
|
||||
version_of,
|
||||
write_template,
|
||||
)
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
|
||||
# 도면 양식은 M02 양식 층(PLAN 10-5)의 `drawing` 종류다.
|
||||
_KIND = "drawing"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,29 +49,27 @@ _SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector")
|
||||
|
||||
A1_TEMPLATE = "00_template_A1"
|
||||
COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표
|
||||
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
|
||||
# A1 내부 작도 영역(템플릿 좌표) — 양식에 `drawing_area` 칸이 없을 때만 쓴다.
|
||||
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
|
||||
# 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를
|
||||
# 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자).
|
||||
_CONTENT_MARGIN = 0.02
|
||||
|
||||
# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각
|
||||
# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다
|
||||
# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능").
|
||||
COMPANY_TEMPLATE_SUBDIR = "templates"
|
||||
|
||||
# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
|
||||
# 읽는 차례 = 프로젝트 작업본(`{프로젝트}/templates/drawing/`) → 없으면 시스템 양식
|
||||
# (`resources/master_template/drawing/`, 읽기 전용). 설계자가 고친 도각은 작업본에 쌓인다.
|
||||
#
|
||||
# 이 요청이 읽을 프로젝트 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
|
||||
# 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다.
|
||||
# `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다.
|
||||
_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None)
|
||||
_project_root: ContextVar[Path | None] = ContextVar("b07_project_template_root", default=None)
|
||||
|
||||
|
||||
def use_company_templates(company_dir: Path | None) -> None:
|
||||
"""이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각."""
|
||||
_company_dir.set(company_dir)
|
||||
def use_project_templates(project_root: Path | None) -> None:
|
||||
"""이 요청이 읽을 프로젝트 폴더를 정한다. None이면 시스템 양식."""
|
||||
_project_root.set(project_root)
|
||||
|
||||
|
||||
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
|
||||
# 이 요청이 도각 표제란에 채울 값. 프로젝트 폴더와 같은 이유로 문맥 변수다 —
|
||||
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
|
||||
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
|
||||
|
||||
@@ -78,9 +88,14 @@ def add_title_fields(extra: dict[str, str]) -> None:
|
||||
_title_fields.set({**_title_fields.get(), **(extra or {})})
|
||||
|
||||
|
||||
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
|
||||
"""회사 도각 파일 경로(없을 수도 있다)."""
|
||||
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
|
||||
def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path:
|
||||
"""프로젝트 작업본 도각 파일 경로(없을 수도 있다)."""
|
||||
return template_path(project_dir(project_root), _KIND, name)
|
||||
|
||||
|
||||
def system_template_path(name: str = A1_TEMPLATE) -> Path:
|
||||
"""시스템 도각 파일 경로."""
|
||||
return template_path(system_dir(), _KIND, name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
@@ -90,18 +105,35 @@ def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_template(name: str) -> dict[str, Any] | None:
|
||||
"""회사 도각이 있으면 그것, 없으면 프로그램 기본 도각."""
|
||||
company_dir = _company_dir.get()
|
||||
if company_dir is not None:
|
||||
override = company_template_path(company_dir, name)
|
||||
"""프로젝트 작업본이 있으면 그것, 없으면 시스템 양식(옛 프로젝트)."""
|
||||
project_root = _project_root.get()
|
||||
if project_root is not None:
|
||||
override = project_template_path(project_root, name)
|
||||
if override.is_file():
|
||||
return _read_template(str(override), override.stat().st_mtime_ns)
|
||||
path = _TEMPLATE_DIR / f"{name}.json"
|
||||
path = system_template_path(name)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return _read_template(str(path), path.stat().st_mtime_ns)
|
||||
|
||||
|
||||
def drawing_area(name: str = A1_TEMPLATE) -> tuple[float, float, float, float]:
|
||||
"""양식의 내부 작도 영역 (x0, y0, x1, y1) — 콘텐츠가 이 영역 중앙에 놓인다.
|
||||
|
||||
양식 칸 `drawing_area` 를 읽는다. 없거나 어긋나면 A1 기본값(`_A1_INNER`).
|
||||
"""
|
||||
area = (_load_template(name) or {}).get("drawing_area")
|
||||
if (
|
||||
isinstance(area, list)
|
||||
and len(area) == 4
|
||||
and all(isinstance(value, (int, float)) for value in area)
|
||||
and area[0] < area[2]
|
||||
and area[1] < area[3]
|
||||
):
|
||||
return (float(area[0]), float(area[1]), float(area[2]), float(area[3]))
|
||||
return _A1_INNER
|
||||
|
||||
|
||||
def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]:
|
||||
"""도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다."""
|
||||
template = _load_template(name)
|
||||
@@ -157,33 +189,52 @@ def validate_template_entities(entities: list[dict[str, Any]]) -> None:
|
||||
_check(entity, f"#{index}")
|
||||
|
||||
|
||||
def clear_company_template(company_dir: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""회사 도각을 지워 프로그램 기본 도각으로 되돌린다. 지울 것이 없으면 False."""
|
||||
path = company_template_path(company_dir, name)
|
||||
if not path.is_file():
|
||||
def is_project_template_customized(project_root: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""작업본이 처음 복사한 도각(`_initial/`, 없으면 시스템)과 다른가."""
|
||||
current = version_of(project_template_path(project_root, name))
|
||||
if current is None:
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
initial = version_of(template_path(initial_dir(project_root), _KIND, name))
|
||||
return current != (initial or version_of(system_template_path(name)))
|
||||
|
||||
|
||||
def save_company_template(
|
||||
company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
|
||||
def reset_project_template(project_root: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""[기본 도각으로] — 처음 복사한 도각(`_initial/`)을 작업본에 덮어쓴다.
|
||||
|
||||
`_initial/` 이 없는 옛 프로젝트는 작업본을 지워 시스템 양식으로 떨어뜨린다.
|
||||
되돌릴 것이 없으면 False.
|
||||
"""
|
||||
if reset_project(project_root, kind=_KIND, name=name):
|
||||
return True
|
||||
return delete_template(project_dir(project_root), _KIND, name)
|
||||
|
||||
|
||||
def save_project_template(
|
||||
project_root: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
|
||||
) -> Path:
|
||||
"""편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다."""
|
||||
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다."""
|
||||
validate_template_entities(entities)
|
||||
path = company_template_path(company_dir, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 작도 영역은 CAD 가 모르는 칸이라 편집본에 없다 — 고치기 전 양식의 값을 이어 받는다.
|
||||
area = (_load_template_at(project_root, name) or {}).get("drawing_area")
|
||||
document = {
|
||||
"format": DRAWING_FORMAT,
|
||||
"source": "B07 도각 편집 화면",
|
||||
**({"drawing_area": area} if area else {}),
|
||||
# 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다.
|
||||
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
|
||||
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
|
||||
}
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return path
|
||||
write_template(project_dir(project_root), _KIND, name, document)
|
||||
return project_template_path(project_root, name)
|
||||
|
||||
|
||||
def _load_template_at(project_root: Path, name: str) -> dict[str, Any] | None:
|
||||
"""요청 문맥과 상관없이 이 프로젝트가 읽는 도각."""
|
||||
token = _project_root.set(project_root)
|
||||
try:
|
||||
return _load_template(name)
|
||||
finally:
|
||||
_project_root.reset(token)
|
||||
|
||||
|
||||
def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
|
||||
@@ -293,15 +344,15 @@ def usable_bbox() -> tuple[float, float, float, float]:
|
||||
여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 —
|
||||
내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다.
|
||||
"""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
ix0, iy0, ix1, iy1 = drawing_area()
|
||||
width, height = usable_area()
|
||||
cx, cy = (ix0 + ix1) / 2.0, (iy0 + iy1) / 2.0
|
||||
return (cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0)
|
||||
|
||||
|
||||
def usable_area() -> tuple[float, float]:
|
||||
"""A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
def usable_area(name: str = A1_TEMPLATE) -> tuple[float, float]:
|
||||
"""양식 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
|
||||
ix0, iy0, ix1, iy1 = drawing_area(name)
|
||||
return (
|
||||
(ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN),
|
||||
(iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN),
|
||||
@@ -444,8 +495,8 @@ def frame_entities(
|
||||
content_w = max(max_x - min_x, 1e-6)
|
||||
content_h = max(max_y - min_y, 1e-6)
|
||||
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
usable_w, usable_h = usable_area()
|
||||
ix0, iy0, ix1, iy1 = drawing_area(template_name)
|
||||
usable_w, usable_h = usable_area(template_name)
|
||||
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
|
||||
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
|
||||
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
|
||||
|
||||
@@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
extract_quantity_table,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
use_company_templates,
|
||||
use_project_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
@@ -292,9 +292,8 @@ async def get_design_drawing(
|
||||
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
|
||||
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
|
||||
use_company_templates(project_root.parent.parent)
|
||||
# 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트).
|
||||
use_project_templates(project_root)
|
||||
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
||||
use_title_fields(await title_block_fields(project_id))
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
|
||||
@@ -18,12 +18,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
clear_company_template,
|
||||
company_template_path,
|
||||
frame_document,
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
use_company_templates,
|
||||
is_project_template_customized,
|
||||
reset_project_template,
|
||||
save_project_template,
|
||||
use_project_templates,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
@@ -42,28 +42,27 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
async def _company_dir(project_id: UUID) -> Path:
|
||||
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`."""
|
||||
async def _project_root(project_id: UUID) -> Path:
|
||||
"""프로젝트 저장 폴더 — `storage/{회사}/{사용자}/{프로젝트}`. 도각 작업본이 여기 산다."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
return root.parent.parent
|
||||
return Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
|
||||
|
||||
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
|
||||
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
|
||||
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
|
||||
"""도각 편집 화면이 실을 도각 한 장. 프로젝트 작업본이 있으면 그것, 없으면 시스템 양식."""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
use_company_templates(company_dir)
|
||||
project_root = await _project_root(project_id)
|
||||
use_project_templates(project_root)
|
||||
# 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는
|
||||
# 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다).
|
||||
fields = await title_block_fields(project_id)
|
||||
return FrameTemplateResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_template_document(),
|
||||
customized=company_template_path(company_dir).is_file(),
|
||||
customized=is_project_template_customized(project_root),
|
||||
fields=fields,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -123,7 +122,7 @@ async def import_frame_template(
|
||||
) -> FrameTemplateImportResponse | JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
|
||||
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
|
||||
"""
|
||||
try:
|
||||
data = await file.read()
|
||||
@@ -150,7 +149,7 @@ async def import_frame_template(
|
||||
async def put_frame_template(
|
||||
project_id: UUID, request: FrameTemplateSaveRequest
|
||||
) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
||||
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 그대로 둔다.
|
||||
|
||||
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
||||
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
||||
@@ -159,8 +158,8 @@ async def put_frame_template(
|
||||
entities = request.drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
raise ValueError("도각 엔티티가 없습니다.")
|
||||
company_dir = await _company_dir(project_id)
|
||||
await asyncio.to_thread(save_company_template, company_dir, entities)
|
||||
project_root = await _project_root(project_id)
|
||||
await asyncio.to_thread(save_project_template, project_root, entities)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id))
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
@@ -174,15 +173,18 @@ async def put_frame_template(
|
||||
|
||||
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
||||
"""[기본 도각으로] — 프로젝트 `_initial/` 도각을 작업본에 덮어쓴다(재계산 아님).
|
||||
|
||||
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
||||
되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 되돌린 도각으로 나온다.
|
||||
"""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
removed = await asyncio.to_thread(clear_company_template, company_dir)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
|
||||
project_root = await _project_root(project_id)
|
||||
await asyncio.to_thread(reset_project_template, project_root)
|
||||
return FrameTemplateSaveResponse(
|
||||
project_id=str(project_id),
|
||||
customized=is_project_template_customized(project_root),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
|
||||
@@ -137,7 +137,7 @@ class FrameTemplateSaveRequest(BaseModel):
|
||||
|
||||
|
||||
class FrameTemplateSaveResponse(BaseModel):
|
||||
"""회사 도각 저장 결과."""
|
||||
"""프로젝트 도각 저장 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
|
||||
@@ -38,14 +38,20 @@ import {
|
||||
exportDrawing,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
invalidateDesignDrawing,
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
type CadDrawing,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
|
||||
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
|
||||
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
@@ -62,33 +68,13 @@ interface DesignMeta {
|
||||
}
|
||||
|
||||
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
||||
interface SaveResult {
|
||||
drawing: CadDrawing;
|
||||
quantityTable: QuantityTable | null;
|
||||
}
|
||||
|
||||
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const B07_CAD_APP_URL = "/b07-cad/index.html";
|
||||
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
||||
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
||||
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
||||
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
||||
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
||||
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
||||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 화면 조립
|
||||
* -------------------------------------------------------------------------- */
|
||||
@@ -114,71 +100,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
: "도면 목록을 불러오지 못했습니다.";
|
||||
}
|
||||
|
||||
const cadHost = document.createElement("div");
|
||||
cadHost.className = "b07-cad-host";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "b07-cad-frame";
|
||||
frame.src = B07_CAD_APP_URL;
|
||||
frame.title = L("B07_Design_Title");
|
||||
const license = document.createElement("a");
|
||||
license.className = "b07-cad-license";
|
||||
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
||||
license.target = "_blank";
|
||||
license.rel = "noreferrer";
|
||||
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
||||
cadHost.append(frame, license);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad:
|
||||
| {
|
||||
drawing: CadDrawing;
|
||||
meta: DesignMeta | null;
|
||||
frameEdit: boolean;
|
||||
frameFields: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
|
||||
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
|
||||
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
|
||||
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
|
||||
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
|
||||
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
|
||||
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
|
||||
const CAD_READY_TIMEOUT_MS = 20000;
|
||||
const CAD_LOAD_TIMEOUT_MS = 15000;
|
||||
let loadWatchdog: number | undefined;
|
||||
|
||||
const failCad = (detail: string): void => {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error = detail;
|
||||
showToast(detail, "error");
|
||||
};
|
||||
|
||||
const stopLoadWatchdog = (): void => {
|
||||
if (loadWatchdog === undefined) return;
|
||||
window.clearTimeout(loadWatchdog);
|
||||
loadWatchdog = undefined;
|
||||
};
|
||||
|
||||
const startLoadWatchdog = (): void => {
|
||||
stopLoadWatchdog();
|
||||
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
|
||||
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
|
||||
loadWatchdog = window.setTimeout(() => {
|
||||
loadWatchdog = undefined;
|
||||
if (cadHost.dataset.loading !== "true") return;
|
||||
failCad(
|
||||
cadReady
|
||||
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
|
||||
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
|
||||
);
|
||||
}, wait);
|
||||
};
|
||||
|
||||
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
|
||||
frame.addEventListener("error", () => {
|
||||
stopLoadWatchdog();
|
||||
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
|
||||
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
|
||||
title: L("B07_Design_Title"),
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
onChanged: (dirty) => {
|
||||
if (!frameEditor.isEditing()) cadDirty = dirty;
|
||||
},
|
||||
onNavigate: (direction) => navigateDrawing(direction),
|
||||
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
|
||||
});
|
||||
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
@@ -188,7 +119,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
@@ -262,23 +192,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
hasNext: index < drawings.length - 1,
|
||||
});
|
||||
|
||||
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
||||
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
|
||||
const sendLoad = (
|
||||
drawing: CadDrawing,
|
||||
meta: DesignMeta | null,
|
||||
frameEdit = false,
|
||||
frameFields: Record<string, string> = {},
|
||||
) => {
|
||||
pendingLoad = { drawing, meta, frameEdit, frameFields };
|
||||
if (!cadReady) return;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
|
||||
window.location.origin,
|
||||
);
|
||||
pendingLoad = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
|
||||
*
|
||||
@@ -349,9 +262,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cadHost.dataset.loading = "true";
|
||||
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
||||
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
|
||||
try {
|
||||
const response = await requestDrawing(drawing);
|
||||
currentDrawing = drawing;
|
||||
@@ -360,10 +271,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
|
||||
applyConfirmButtonState();
|
||||
updateInfoPanel(drawing, response);
|
||||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||||
cad.load(response.drawing, buildMeta(drawing, response, index));
|
||||
} catch (error) {
|
||||
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
||||
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
@@ -384,16 +294,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
void loadDrawing(drawings[target], target);
|
||||
};
|
||||
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
|
||||
}, 5000);
|
||||
});
|
||||
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
|
||||
|
||||
async function confirmCurrentDrawing(): Promise<void> {
|
||||
if (!projectId || !currentDrawing) return;
|
||||
@@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const frameEditor = createFrameTemplateEditor({
|
||||
projectId: projectId as string,
|
||||
sendLoad,
|
||||
const frameEditor = createFrameTemplateEditor<CadDrawing>({
|
||||
api: {
|
||||
fetch: () => fetchFrameTemplate(projectId as string),
|
||||
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
|
||||
importFile: (file) => importFrameTemplate(projectId as string, file),
|
||||
reset: () => resetFrameTemplate(projectId as string),
|
||||
},
|
||||
sendLoad: (drawing, meta, frameEdit, frameFields) =>
|
||||
cad.load(drawing, meta, frameEdit, frameFields),
|
||||
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
|
||||
restoreDrawing: () => {
|
||||
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
|
||||
@@ -513,77 +420,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
||||
});
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
drawing?: CadDrawing;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
fileFormat?: "dxf" | "dwg";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
if (message.type === CAD_TOAST_MESSAGE) {
|
||||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
kind ?? "info",
|
||||
duration,
|
||||
actionId
|
||||
? () =>
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
|
||||
window.location.origin,
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
} else if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) {
|
||||
sendLoad(
|
||||
pendingLoad.drawing,
|
||||
pendingLoad.meta,
|
||||
pendingLoad.frameEdit,
|
||||
pendingLoad.frameFields,
|
||||
);
|
||||
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
|
||||
if (cadHost.dataset.loading === "true") startLoadWatchdog();
|
||||
}
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
||||
void exportDrawingFile(message.fileFormat);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({
|
||||
drawing: message.drawing,
|
||||
quantityTable: message.quantityTable ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
@@ -604,7 +440,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
steps: workflowSteps(),
|
||||
activeStep: 4,
|
||||
leftPanel: drawingPanel,
|
||||
mainContent: cadHost,
|
||||
mainContent: cad.element,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
@@ -624,7 +460,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
if (drawings.length > 0) {
|
||||
void loadDrawing(drawings[0], 0).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
|
||||
@@ -157,76 +157,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* CAD 뷰어 호스트 (상세 페이지 영역) */
|
||||
.b07-cad-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
|
||||
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
|
||||
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
|
||||
.b07-cad-host[data-loading="true"]::after {
|
||||
content: "도면을 불러오는 중…";
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b07-cad-host[data-error]:not([data-error=""])::before {
|
||||
content: attr(data-error);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 70%;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-danger, #d33);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-danger, #d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* B07 독립형 CAD 앱 임베드 */
|
||||
.b07-cad-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.b07-cad-license {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
bottom: 7px;
|
||||
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.b07-cad-license:hover {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
|
||||
.b07-info-host:empty {
|
||||
display: none;
|
||||
@@ -294,40 +224,6 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
|
||||
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
|
||||
.b07-frame-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.b07-frame-edit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-frame-edit__label {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b07-frame-edit__buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-frame-edit__buttons > button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
|
||||
@@ -55,6 +55,9 @@ button {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cad-host-save {
|
||||
--cad-title-height: 0px;
|
||||
}
|
||||
.controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -966,11 +969,24 @@ body > canvas[data-id="canvas"] {
|
||||
}
|
||||
|
||||
.cad-frame-tokens__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__fold {
|
||||
flex: none;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--cad-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3,10 +3,10 @@ import { toast } from 'react-toastify';
|
||||
import { clearRecovery, restoreRecovery } from '../helpers/autosave';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { requestDrawingExport } from '../integration/aislo-drawing-bridge';
|
||||
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { redo, undo } from '../state';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
|
||||
export const FILE_COMMANDS: CadCommand[] = [
|
||||
@@ -17,6 +17,10 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
glyph: '💾',
|
||||
hint: '현재 도면을 브라우저에 저장한다',
|
||||
run: () => {
|
||||
if (isHostSave()) {
|
||||
requestHostSave();
|
||||
return '양식 저장 요청';
|
||||
}
|
||||
void exportEntitiesToLocalStorage().then(() => {
|
||||
clearRecovery();
|
||||
toast.success('도면을 저장했습니다.');
|
||||
|
||||
@@ -113,6 +113,8 @@ function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: n
|
||||
|
||||
export const FramePlaceholderPanel: FC = () => {
|
||||
const [visible, setVisible] = useState(isFrameEditMode());
|
||||
// 접으면 그림 오른쪽 아래를 덮지 않는다 — 머리 줄만 남는다.
|
||||
const [folded, setFolded] = useState(false);
|
||||
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
@@ -139,8 +141,21 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
return (
|
||||
<section className="cad-frame-tokens controls">
|
||||
<header className="cad-frame-tokens__header">
|
||||
자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
|
||||
<span>
|
||||
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cad-frame-tokens__fold"
|
||||
aria-expanded={!folded}
|
||||
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
|
||||
onClick={() => setFolded(!folded)}
|
||||
>
|
||||
{folded ? '▴' : '▾'}
|
||||
</button>
|
||||
</header>
|
||||
{!folded && (
|
||||
<>
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">글자</span>
|
||||
{TEXT_TOKENS.map((token) => (
|
||||
@@ -190,6 +205,8 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
|
||||
import type { FC } from 'react';
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
|
||||
import { getHostTitle, isHostSave } from '../state';
|
||||
|
||||
export const QuickAccessBar: FC = () => (
|
||||
export const QuickAccessBar: FC = () => {
|
||||
const [hostTitle, setTitle] = useState(getHostTitle());
|
||||
useEffect(() => {
|
||||
const refresh = () => setTitle(getHostTitle());
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, []);
|
||||
// 부모 페이지가 저장·이름을 맡는 M02 에선 제목 줄을 숨겨 그림 칸을 넓힌다.
|
||||
if (isHostSave()) return null;
|
||||
return (
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B07 상세 설계</span>
|
||||
<span>{hostTitle}</span>
|
||||
</div>
|
||||
<div className="cad-quick-access">
|
||||
{QUICK_ACCESS_COMMANDS.map((id) => {
|
||||
@@ -32,3 +43,4 @@ export const QuickAccessBar: FC = () => (
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveCommandInput,
|
||||
} from '../commands/registry.ts';
|
||||
import { runCommand } from '../commands/run-command.ts';
|
||||
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
||||
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
getSnapEnabled,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isHostSave,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
@@ -426,6 +428,9 @@ export class InputController {
|
||||
} else if (evt.ctrlKey && evt.key === 'y') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 's') {
|
||||
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
|
||||
if (isHostSave()) requestHostSave();
|
||||
} else if (evt.ctrlKey && evt.key === 'a') {
|
||||
// User wants to select everything
|
||||
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setFrameEditMode,
|
||||
setHostReadOnly,
|
||||
setHostSave,
|
||||
setHostTitle,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -36,6 +39,7 @@ export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
|
||||
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
|
||||
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
|
||||
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
|
||||
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
|
||||
|
||||
interface DrawingLoadMessage {
|
||||
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
||||
@@ -45,6 +49,14 @@ interface DrawingLoadMessage {
|
||||
frameEdit?: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
|
||||
frameFields?: Record<string, string>;
|
||||
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
|
||||
readOnly?: boolean;
|
||||
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
||||
hostTitle?: string;
|
||||
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
|
||||
hostSave?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -67,6 +79,11 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
|
||||
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
|
||||
}
|
||||
|
||||
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
|
||||
export function requestHostSave() {
|
||||
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
|
||||
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
|
||||
@@ -187,6 +204,9 @@ export function registerAisloDrawingBridge() {
|
||||
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
||||
setHostReadOnly(event.data.readOnly === true);
|
||||
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
||||
setHostSave(event.data.hostSave === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
@@ -194,7 +214,7 @@ export function registerAisloDrawingBridge() {
|
||||
// 상태를 이어 갈 이유도 없다.
|
||||
runCommandInput('SELECT');
|
||||
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
|
||||
setRecoveryScope(event.data.meta?.drawingId ?? null);
|
||||
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
|
||||
} catch (error) {
|
||||
|
||||
@@ -190,6 +190,10 @@ let designMeta: DesignMeta | null = null;
|
||||
let frameEditMode = false;
|
||||
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
|
||||
let frameFields: Record<string, string> = {};
|
||||
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
|
||||
let hostReadOnly = false;
|
||||
let hostTitle = 'B07 상세 설계';
|
||||
let hostSave = false;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -267,7 +271,7 @@ export const isDrawingDirty = () => drawingDirty;
|
||||
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
|
||||
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
|
||||
*/
|
||||
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
|
||||
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
@@ -493,6 +497,24 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
|
||||
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
|
||||
/** 저장(💾 · Ctrl+S)을 부모 페이지 [저장]에 맡기는가 — M02 양식만 켠다. */
|
||||
export const isHostSave = (): boolean => hostSave;
|
||||
export const setHostSave = (enabled: boolean) => {
|
||||
hostSave = enabled;
|
||||
// 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`).
|
||||
document.documentElement.classList.toggle('cad-host-save', enabled);
|
||||
};
|
||||
export const getHostTitle = (): string => hostTitle;
|
||||
export const setHostTitle = (title: string) => {
|
||||
hostTitle = title;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setHostReadOnly = (readOnly: boolean) => {
|
||||
hostReadOnly = readOnly;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
|
||||
frameEditMode = enabled;
|
||||
frameFields = enabled ? fields : {};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
searchElements,
|
||||
type ElementBrief,
|
||||
@@ -207,29 +208,13 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
|
||||
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
const body = el("div", { className: "m01lab__modal-body" });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01lab__modal",
|
||||
attrs: { role: "dialog", "aria-label": title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab__modal-top",
|
||||
children: [
|
||||
el("h3", { text: title }),
|
||||
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: tl("Modal_Close"),
|
||||
mount,
|
||||
dialogClass: "m01-logic__pick m01lab__modal",
|
||||
backdropClass: "m01-logic__backdrop",
|
||||
});
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
mount(body);
|
||||
document.body.append(backdrop);
|
||||
dialog.tabIndex = -1;
|
||||
dialog.focus();
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text });
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Api_Fetch.ts
|
||||
* 마스터 템플릿 서버 호출 — 계약 `6_계약.md` 서버 길 · 시스템 층은 시스템 관리자만
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */
|
||||
export type Kind = "table" | "drawing";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
이름: string;
|
||||
판: string;
|
||||
수정일: string;
|
||||
}
|
||||
|
||||
export interface TemplateDoc {
|
||||
종류: Kind;
|
||||
이름: string;
|
||||
/** 프로젝트 층에서 작업본이 아직 없으면(옛 프로젝트) 시스템 양식이 와서 null */
|
||||
판: string | null;
|
||||
문서: unknown;
|
||||
}
|
||||
|
||||
/** 서버가 판 불일치로 막았을 때 */
|
||||
export class StaleError extends Error {}
|
||||
|
||||
async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (response.status === 409)
|
||||
throw new StaleError(typeof body.detail === "string" ? body.detail : "409");
|
||||
if (!response.ok) {
|
||||
const detail = body.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
|
||||
const head = (layer: Layer): string =>
|
||||
layer === "system" ? "/templates" : `/layers/${layer}/templates`;
|
||||
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
|
||||
`${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`;
|
||||
|
||||
/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */
|
||||
export const listTemplates = async (
|
||||
layer: Layer,
|
||||
projectId: string | null,
|
||||
): Promise<TemplateInfo[]> => {
|
||||
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
|
||||
layer === "system" ? head(layer) : `${head(layer)}${q(projectId)}`,
|
||||
);
|
||||
return Array.isArray(got) ? got : got.양식;
|
||||
};
|
||||
|
||||
export const readTemplate = (
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
projectId: string | null,
|
||||
): Promise<TemplateDoc> => call(item(layer, kind, name, projectId));
|
||||
|
||||
export const saveTemplate = (
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
projectId: string | null,
|
||||
판: string,
|
||||
문서: unknown,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) });
|
||||
|
||||
/** 시스템 층만 지움 */
|
||||
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
|
||||
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */
|
||||
export const deleteLayerTemplate = (
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
판: string,
|
||||
): Promise<{ ok: boolean }> =>
|
||||
call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
call(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
export const resetProject = (id: string): Promise<unknown> => post(project(id, "reset"));
|
||||
|
||||
export const applyToProject = (
|
||||
id: string,
|
||||
from: { from: Layer; user_id?: string; project_id?: string; 종류?: Kind; 이름?: string },
|
||||
): Promise<unknown> => post(project(id, "apply"), from);
|
||||
|
||||
export const saveProjectAs = (
|
||||
id: string,
|
||||
to: "personal" | "company",
|
||||
종류: Kind,
|
||||
이름: string,
|
||||
): Promise<unknown> => post(project(id, "save-as"), { to, 종류, 이름 });
|
||||
|
||||
export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${enc(id)}/sources`);
|
||||
|
||||
/** 설계값을 채운 표 문서 — 서버가 그때그때 채움 · 저장 안 함 */
|
||||
export const readFilled = (id: string, name: string): Promise<TemplateDoc> =>
|
||||
call(`/projects/${enc(id)}/tables/${enc(name)}/filled`);
|
||||
@@ -0,0 +1,86 @@
|
||||
/* M02 도면 양식 편집 부품 — 위 도구 줄 한 줄(작도 영역 · 단추) + 아래 웹캐드. */
|
||||
|
||||
/* 도면 칸이 메인을 꽉 채우고 창 크기를 따른다 — 메인 칸(`ui-workflow-layout__main`)은
|
||||
높이가 정해지지 않아(min-height 만) 100% 가 안 먹으므로 화면 높이로 잡는다.
|
||||
표 양식일 때는 페이지 쪽 모양 그대로 두려고 도면을 품은 때만 건다. */
|
||||
.m02-main:has(> .m02-main__host > .m02-drawing) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-main__host:has(> .m02-drawing) {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-drawing {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-drawing > .cad-host {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.m02-drawing__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16, 16px);
|
||||
}
|
||||
|
||||
.m02-drawing__title {
|
||||
align-self: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m02-drawing__areabtn {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-drawing__areabtn.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 작은 창 안 칸 넷 — 이름이 다 보이는 넉넉한 폭 */
|
||||
.m02-drawing__areaform {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(9rem, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
min-width: min(22rem, 80vw);
|
||||
}
|
||||
|
||||
.m02-drawing__toolbar--slot {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-drawing__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-pop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m02-pop > .ui-modal {
|
||||
position: absolute;
|
||||
width: 23rem;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Drawing.ts
|
||||
* M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3).
|
||||
*
|
||||
* 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})`
|
||||
* → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다.
|
||||
* `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다.
|
||||
*
|
||||
* 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` ·
|
||||
* `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./M02_MasterTemplete_Drawing.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
|
||||
/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */
|
||||
export interface DrawingTemplateDoc extends CadHostDrawing {
|
||||
format?: number;
|
||||
source?: string;
|
||||
drawing_area?: [number, number, number, number];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DrawingTemplateOptions {
|
||||
/** CAD 안 💾 · Ctrl+S 가 부른다 — 페이지 머리 [저장] 과 같은 일(판 · 409 흐름)을 하게 페이지가 넘긴다. */
|
||||
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
|
||||
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
|
||||
name?: string;
|
||||
/** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */
|
||||
layer?: string;
|
||||
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
|
||||
projectId?: string | null;
|
||||
/** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */
|
||||
onChanged?: (dirty: boolean) => void;
|
||||
/** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸 · 파일 불러오기를 거기 한 줄로 놓는다. */
|
||||
headerSlot?: HTMLElement;
|
||||
}
|
||||
|
||||
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
|
||||
|
||||
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
|
||||
function recoveryScopeOf(options: DrawingTemplateOptions): string {
|
||||
const name = options.name ?? "drawing";
|
||||
if (!options.layer) return `m02:${name}`;
|
||||
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
|
||||
return `m02:${options.layer}${project}:${name}`;
|
||||
}
|
||||
|
||||
/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */
|
||||
function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void {
|
||||
const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`;
|
||||
if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return;
|
||||
try {
|
||||
const value = localStorage.getItem(old);
|
||||
if (value === null) return;
|
||||
if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) {
|
||||
localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value);
|
||||
}
|
||||
localStorage.removeItem(old);
|
||||
} catch {
|
||||
// 저장소를 못 써도 편집은 됨
|
||||
}
|
||||
}
|
||||
|
||||
export interface DrawingTemplateHandle {
|
||||
getDoc: () => Promise<DrawingTemplateDoc>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
|
||||
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
|
||||
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
|
||||
|
||||
async function importDrawingFile(
|
||||
file: File,
|
||||
): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
drawing: DrawingTemplateDoc;
|
||||
entity_count: number;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function mountDrawingTemplate(
|
||||
container: HTMLElement,
|
||||
doc: DrawingTemplateDoc,
|
||||
options: DrawingTemplateOptions = {},
|
||||
): DrawingTemplateHandle {
|
||||
const readOnly = options.readOnly === true;
|
||||
const recoveryScope = recoveryScopeOf(options);
|
||||
migrateOldRecovery(options, recoveryScope);
|
||||
let base: DrawingTemplateDoc = doc;
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "m02-drawing";
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "m02-drawing__toolbar";
|
||||
|
||||
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. 제목 줄에는 값이 다 보이는 단추 하나,
|
||||
// 누르면 칸 넷이 작은 창으로 펼쳐진다(칸은 창을 닫아도 그대로 살아 있음).
|
||||
const areaForm = document.createElement("div");
|
||||
areaForm.className = "m02-drawing__areaform";
|
||||
const start = doc.drawing_area ?? DEFAULT_AREA;
|
||||
const areaInputs = AREA_LABELS.map((label, index) => {
|
||||
const field = createInputField({ label, type: "number", value: String(start[index]) });
|
||||
field.input.disabled = readOnly;
|
||||
field.input.addEventListener("input", () => {
|
||||
options.onChanged?.(true);
|
||||
readArea();
|
||||
});
|
||||
return field;
|
||||
});
|
||||
areaForm.append(...areaInputs.map((field) => field.root));
|
||||
|
||||
const areaButton = createButton({ label: "", variant: "ghost" });
|
||||
areaButton.classList.add("m02-drawing__areabtn");
|
||||
areaButton.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm) · 눌러서 고침";
|
||||
let areaModal: { close: () => void } | null = null;
|
||||
|
||||
const readArea = (): [number, number, number, number] | null => {
|
||||
const values = areaInputs.map((field) => Number(field.input.value));
|
||||
const valid =
|
||||
areaInputs.every((field) => field.input.value.trim() !== "") &&
|
||||
values.every(Number.isFinite) &&
|
||||
values[0] < values[2] &&
|
||||
values[1] < values[3];
|
||||
areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위"));
|
||||
areaButton.textContent = `작도 영역 ${areaInputs.map((f) => f.input.value.trim() || "?").join(" · ")}`;
|
||||
areaButton.classList.toggle("is-invalid", !valid);
|
||||
return valid ? (values as [number, number, number, number]) : null;
|
||||
};
|
||||
readArea();
|
||||
areaButton.addEventListener("click", () => {
|
||||
areaModal?.close();
|
||||
areaModal = openModal({
|
||||
title: "작도 영역",
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal",
|
||||
backdropClass: "m02-pop",
|
||||
mount: (body) => body.append(areaForm),
|
||||
});
|
||||
// 단추 바로 아래 · 오른쪽 맞춤 · 화면 밖으로 안 나가게
|
||||
const dialog = document.querySelector<HTMLElement>(".m02-pop > .ui-modal");
|
||||
if (!dialog) return;
|
||||
const at = areaButton.getBoundingClientRect();
|
||||
const width = dialog.offsetWidth;
|
||||
dialog.style.top = `${at.bottom + 4}px`;
|
||||
dialog.style.left = `${Math.max(8, Math.min(at.right - width, innerWidth - width - 8))}px`;
|
||||
dialog.style.maxHeight = `${innerHeight - at.bottom - 12}px`;
|
||||
});
|
||||
|
||||
// CAD 안 💾 · Ctrl+S — 양식 파일은 페이지 [저장] 만 쓴다. 저장 중 또 눌러도 한 번만 부른다.
|
||||
let saving = false;
|
||||
const hostSave = (): void => {
|
||||
if (readOnly) return void showToast("이 양식은 볼 수만 있습니다.", "info");
|
||||
if (saving || !options.onSave) return;
|
||||
saving = true;
|
||||
getDoc()
|
||||
.then((next) => options.onSave?.(next))
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", "error"),
|
||||
)
|
||||
.finally(() => (saving = false));
|
||||
};
|
||||
|
||||
const cad = createCadHost<DrawingTemplateDoc>({
|
||||
title: "도면 양식",
|
||||
onChanged: (dirty) => options.onChanged?.(dirty),
|
||||
onHostSave: hostSave,
|
||||
});
|
||||
const load = (drawing: DrawingTemplateDoc): void => {
|
||||
cad.beginLoading();
|
||||
cad.load(
|
||||
drawing,
|
||||
null,
|
||||
!readOnly,
|
||||
{},
|
||||
{ recoveryScope, readOnly, hostTitle: "도면 양식 편집", hostSave: true },
|
||||
);
|
||||
};
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "m02-drawing__actions";
|
||||
if (!readOnly) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".dxf,.dwg";
|
||||
fileInput.hidden = true;
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
fileInput.value = "";
|
||||
if (!file) return;
|
||||
importButton.disabled = true;
|
||||
importDrawingFile(file)
|
||||
.then((response) => {
|
||||
// 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다.
|
||||
load(response.drawing);
|
||||
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.",
|
||||
"error",
|
||||
),
|
||||
)
|
||||
.finally(() => (importButton.disabled = false));
|
||||
});
|
||||
const importButton = createButton({
|
||||
label: "📂",
|
||||
variant: "ghost",
|
||||
onClick: () => fileInput.click(),
|
||||
});
|
||||
importButton.title = importButton.ariaLabel = "파일 불러오기 (DXF · DWG)";
|
||||
actions.append(importButton, fileInput);
|
||||
}
|
||||
|
||||
const getDoc = async (): Promise<DrawingTemplateDoc> => {
|
||||
const { drawing } = await cad.requestSave();
|
||||
const drawingArea = readArea();
|
||||
if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다.");
|
||||
base = { ...base, ...drawing, drawing_area: drawingArea };
|
||||
return base;
|
||||
};
|
||||
|
||||
// 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게.
|
||||
toolbar.append(areaButton, actions);
|
||||
if (options.headerSlot) {
|
||||
toolbar.classList.add("m02-drawing__toolbar--slot");
|
||||
options.headerSlot.append(toolbar);
|
||||
root.append(cad.element);
|
||||
} else root.append(toolbar, cad.element);
|
||||
container.append(root);
|
||||
load(doc);
|
||||
|
||||
return {
|
||||
getDoc,
|
||||
destroy: () => {
|
||||
areaModal?.close();
|
||||
cad.destroy();
|
||||
toolbar.remove();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""M02 마스터 템플릿 API — 시스템 층(계약 `6_계약.md` 서버 길 첫 묶음).
|
||||
|
||||
⚠ 권한은 등록하는 쪽(`main.py`)이 `system_admin_only` 로 붙임.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
|
||||
|
||||
class SaveBody(BaseModel):
|
||||
판: str = ""
|
||||
문서: Any
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
except store.StoreError as e:
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def get_templates() -> list[dict]:
|
||||
return _call(store.list_all)
|
||||
|
||||
|
||||
@router.get("/templates/{kind}/{name}")
|
||||
def get_template(kind: str, name: str) -> dict:
|
||||
return _call(store.read, kind, name)
|
||||
|
||||
|
||||
@router.put("/templates/{kind}/{name}")
|
||||
def put_template(kind: str, name: str, body: SaveBody) -> dict:
|
||||
return _call(store.write, kind, name, body.판, body.문서)
|
||||
|
||||
|
||||
@router.delete("/templates/{kind}/{name}")
|
||||
def delete_template(kind: str, name: str, 판: str | None = None) -> dict:
|
||||
_call(store.delete, kind, name, 판)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
|
||||
|
||||
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
|
||||
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import ezdxf
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
frame_document,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
|
||||
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
|
||||
_IMPORT_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다.
|
||||
# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다.
|
||||
DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = (
|
||||
("공사명", "프로젝트", "프로젝트 이름"),
|
||||
("위치", "프로젝트", "사업 위치"),
|
||||
("시행청", "프로젝트", "발주 기관"),
|
||||
("연도기번", "프로젝트", "프로젝트 번호"),
|
||||
("사업량", "프로젝트", "사업량"),
|
||||
("설계일자", "프로젝트", "설계 일자"),
|
||||
("용역회사", "프로젝트", "회사 이름"),
|
||||
("설계자", "프로젝트", "설계자 이름"),
|
||||
("과업책임자", "프로젝트", "과업책임자 이름"),
|
||||
("분야별책임자", "프로젝트", "분야별책임자 이름"),
|
||||
("회사로고", "프로젝트", "회사 로고 그림"),
|
||||
("설계자서명", "프로젝트", "설계자 서명 그림"),
|
||||
("과업책임자서명", "프로젝트", "과업책임자 서명 그림"),
|
||||
("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"),
|
||||
("도면명", "도면", "도면 이름"),
|
||||
("도면번호", "도면", "도면 목록 순번"),
|
||||
("축척_A1", "도면", "A1 축척 분모"),
|
||||
("축척_A3", "도면", "A3 축척 분모"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drawing-import")
|
||||
async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
|
||||
try:
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise ValueError("빈 파일입니다.")
|
||||
if len(data) > _IMPORT_MAX_BYTES:
|
||||
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
|
||||
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
|
||||
validate_template_entities(entities)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"drawing": frame_document(entities),
|
||||
"entity_count": len(entities),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, UnicodeError, ezdxf.DXFError):
|
||||
# 엉터리 파일 — 읽는 라이브러리가 ValueError 아닌 것으로 던짐(DXF 아님 · 글자 깨짐).
|
||||
# 던진 글에 임시 폴더 경로가 섞여 있어 그대로 내지 않는다.
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "도각 파일을 읽지 못했습니다 — DXF 가 아니거나 깨진 파일입니다.",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("M02 도각 불러오기 실패: %s", file.filename)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drawing-fields")
|
||||
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
|
||||
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
|
||||
try:
|
||||
values = await title_block_fields(project_id) if project_id else {}
|
||||
except Exception:
|
||||
logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id)
|
||||
values = {}
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"fields": [
|
||||
{"key": key, "source": source, "label": label}
|
||||
for key, source, label in DRAWING_FIELDS
|
||||
],
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」.
|
||||
|
||||
⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서:
|
||||
system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길)
|
||||
company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자)
|
||||
personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기)
|
||||
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
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_Table_Fill as fill
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"])
|
||||
|
||||
Layer = Literal["system", "company", "personal", "project"]
|
||||
|
||||
|
||||
class SaveBody(BaseModel):
|
||||
판: str | None = None
|
||||
문서: dict[str, Any]
|
||||
|
||||
|
||||
class TargetBody(BaseModel):
|
||||
종류: str | None = None
|
||||
이름: str | None = None
|
||||
|
||||
|
||||
class ApplyBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
from_: Layer = Field(alias="from")
|
||||
user_id: int | None = None
|
||||
project_id: str | None = None
|
||||
종류: str | None = None
|
||||
이름: str | None = None
|
||||
|
||||
|
||||
class SaveAsBody(BaseModel):
|
||||
to: Literal["personal", "company"]
|
||||
종류: str
|
||||
이름: str
|
||||
|
||||
|
||||
# ── DB (시험이 바꿔 끼움) ─────────────────────────────
|
||||
|
||||
|
||||
async def _project_row(project_id: str) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, company_id, user_id, storage_path FROM projects
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
keys = ("id", "name", "company_id", "user_id", "storage_path")
|
||||
return dict(zip(keys, row, strict=True))
|
||||
|
||||
|
||||
async def _user_company(user_id: int) -> int | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL", (user_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def _company_users(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id, name FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY id",
|
||||
(company_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"user_id": row[0], "name": row[1]} for row in rows]
|
||||
|
||||
|
||||
async def _company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, storage_path FROM projects
|
||||
WHERE company_id = %s AND deleted_at IS NULL ORDER BY created_at DESC""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows]
|
||||
|
||||
|
||||
async def _cross_designs(project_id: str) -> list[dict[str, Any]]:
|
||||
"""최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님)."""
|
||||
try:
|
||||
context = await run_with_connection(get_workflow_route_context, UUID(str(project_id)))
|
||||
route_id = int((context or {}).get("route_id") or 0)
|
||||
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
|
||||
except Exception:
|
||||
logger.exception("M02 채운 표 — 횡단 설계 조회 실패: project_id=%s", project_id)
|
||||
return []
|
||||
|
||||
|
||||
# ── 권한 · 자리 ───────────────────────────────────────
|
||||
|
||||
|
||||
def _is_system_admin(session: dict[str, Any]) -> bool:
|
||||
return session.get("role") == "SYSTEM_ADMIN"
|
||||
|
||||
|
||||
def _is_company_admin(session: dict[str, Any]) -> bool:
|
||||
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
|
||||
|
||||
|
||||
async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, Any]:
|
||||
"""같은 회사 프로젝트 한 건 + `root`(실경로). 아니면 403/404."""
|
||||
if not project_id:
|
||||
raise HTTPException(status_code=400, detail="project_id 가 필요합니다.")
|
||||
row = await _project_row(project_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if not _is_system_admin(session) and row["company_id"] != session.get("company_id"):
|
||||
raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.")
|
||||
if not row.get("storage_path"):
|
||||
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
row["root"] = Path(resolve_stored_project_path(row["storage_path"]))
|
||||
return row
|
||||
|
||||
|
||||
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="회사 연결이 필요합니다.")
|
||||
return int(company_id)
|
||||
|
||||
|
||||
async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None:
|
||||
if user_id == session.get("user_id"):
|
||||
return
|
||||
if await _user_company(user_id) != company_id:
|
||||
raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.")
|
||||
|
||||
|
||||
async def _layer_dir(
|
||||
session: dict[str, Any],
|
||||
layer: str,
|
||||
*,
|
||||
project_id: str | None,
|
||||
user_id: int | None = None,
|
||||
write: bool = False,
|
||||
) -> Path:
|
||||
"""층 폴더 — 읽기·쓰기 권한까지 여기서 가름."""
|
||||
if layer == "system":
|
||||
if write:
|
||||
raise HTTPException(status_code=403, detail="시스템 양식은 마스터 템플릿 화면에서만.")
|
||||
return layers.system_dir()
|
||||
if layer == "project":
|
||||
return layers.project_dir((await _project(session, project_id))["root"])
|
||||
project = await _project(session, project_id) if project_id else None
|
||||
company_id = _company_of(session, project)
|
||||
if layer == "company":
|
||||
if write and not _is_company_admin(session):
|
||||
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
|
||||
return 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)
|
||||
raise HTTPException(status_code=404, detail="없는 층입니다.")
|
||||
|
||||
|
||||
def _bad(error: ValueError) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=str(error))
|
||||
|
||||
|
||||
# ── 층 길 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/templates")
|
||||
async def list_layer(
|
||||
layer: Layer,
|
||||
project_id: str | None = Query(None),
|
||||
user_id: int | None = Query(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
|
||||
rows = await asyncio.to_thread(layers.list_templates, folder)
|
||||
manifest = await asyncio.to_thread(layers.read_manifest, folder)
|
||||
for row in rows:
|
||||
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
|
||||
return {"층": layer, "양식": rows}
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/templates/{kind}/{name}")
|
||||
async def read_layer_template(
|
||||
layer: Layer,
|
||||
kind: str,
|
||||
name: str,
|
||||
project_id: str | None = Query(None),
|
||||
user_id: int | None = Query(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
|
||||
try:
|
||||
found = await asyncio.to_thread(layers.read_template, folder, kind, name)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if found is None and layer == "project":
|
||||
# 옛 프로젝트(사본 없음) — 시스템 양식으로 떨어짐 · 저장하면 그때 작업본이 생김
|
||||
found = await asyncio.to_thread(layers.read_template, layers.system_dir(), kind, name)
|
||||
if found is not None:
|
||||
found.update({"층": "system", "판": None})
|
||||
return found
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
found["층"] = layer
|
||||
found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}")
|
||||
return found
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/templates/{kind}/{name}")
|
||||
async def save_layer_template(
|
||||
layer: Layer,
|
||||
kind: str,
|
||||
name: str,
|
||||
body: SaveBody,
|
||||
project_id: str | None = Query(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
|
||||
document = body.문서
|
||||
try:
|
||||
layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if kind == "table" and fill.is_fillable(document):
|
||||
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
|
||||
document = fill.strip_design(document)
|
||||
try:
|
||||
version = await asyncio.to_thread(
|
||||
layers.write_template,
|
||||
folder,
|
||||
kind,
|
||||
name,
|
||||
document,
|
||||
version=body.판,
|
||||
check_version=True,
|
||||
)
|
||||
except layers.StaleTemplate as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"message": str(error), "판": error.current}
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
return {"종류": kind, "이름": name, "판": version, "층": layer}
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/templates/{kind}/{name}")
|
||||
async def delete_layer_template(
|
||||
layer: Layer,
|
||||
kind: str,
|
||||
name: str,
|
||||
판: str | None = Query(None),
|
||||
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="개인 · 회사 층 양식만 지울 수 있습니다.")
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
|
||||
try:
|
||||
layers.check_kind(kind)
|
||||
layers.check_name(name)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if not 판:
|
||||
raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.")
|
||||
try:
|
||||
found = await asyncio.to_thread(
|
||||
layers.delete_template, folder, kind, name, version=판, check_version=True
|
||||
)
|
||||
except layers.StaleTemplate as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"message": str(error), "판": error.current}
|
||||
) from error
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
return {"종류": kind, "이름": name, "층": layer, "지움": True}
|
||||
|
||||
|
||||
# ── 프로젝트 길 ───────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/templates/reset")
|
||||
async def reset_project_templates(
|
||||
project_id: str,
|
||||
body: TargetBody | None = None,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
project = await _project(session, project_id)
|
||||
target = body or TargetBody()
|
||||
try:
|
||||
done = await asyncio.to_thread(
|
||||
layers.reset_project, project["root"], kind=target.종류, name=target.이름
|
||||
)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
return {"초기화": done}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/templates/apply")
|
||||
async def apply_project_templates(
|
||||
project_id: str,
|
||||
body: ApplyBody,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""다른 층 양식을 작업본에 덮어씀 — [회사 양식 적용] · [양식 가져오기].
|
||||
|
||||
`_initial/` 은 그대로 — 초기화 기준은 안 바뀜.
|
||||
"""
|
||||
project = await _project(session, project_id)
|
||||
ref: dict[str, Any] = {}
|
||||
if body.from_ == "project":
|
||||
if not body.project_id or body.project_id == project_id:
|
||||
raise HTTPException(status_code=400, detail="가져올 다른 프로젝트를 고르세요.")
|
||||
other = await _project(session, body.project_id)
|
||||
if other["company_id"] != project["company_id"]:
|
||||
raise HTTPException(status_code=403, detail="같은 회사 프로젝트만 가져옵니다.")
|
||||
source = layers.project_dir(other["root"])
|
||||
ref = {"project_id": body.project_id, "프로젝트": other.get("name")}
|
||||
elif body.from_ == "personal":
|
||||
owner = body.user_id if body.user_id is not None else int(session["user_id"])
|
||||
await _same_company_user(session, owner, int(project["company_id"]))
|
||||
source = layers.personal_dir(project["company_id"], owner)
|
||||
ref = {"user_id": owner}
|
||||
elif body.from_ == "company":
|
||||
source = layers.company_dir(project["company_id"])
|
||||
else:
|
||||
source = layers.system_dir()
|
||||
try:
|
||||
done = await asyncio.to_thread(
|
||||
layers.copy_templates,
|
||||
source,
|
||||
layers.project_dir(project["root"]),
|
||||
source_layer=body.from_,
|
||||
kind=body.종류,
|
||||
name=body.이름,
|
||||
source_ref=ref,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if not done:
|
||||
raise HTTPException(status_code=404, detail="가져올 양식이 없습니다.")
|
||||
return {"적용": done, "from": body.from_}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/templates/save-as")
|
||||
async def save_project_template_as(
|
||||
project_id: str,
|
||||
body: SaveAsBody,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""작업본 한 벌을 [내 양식으로 저장] · [회사 공식으로 저장]."""
|
||||
project = await _project(session, project_id)
|
||||
if body.to == "company" and not _is_company_admin(session):
|
||||
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
|
||||
if body.to == "company":
|
||||
target = layers.company_dir(project["company_id"])
|
||||
else:
|
||||
target = layers.personal_dir(project["company_id"], session["user_id"])
|
||||
try:
|
||||
done = await asyncio.to_thread(
|
||||
layers.copy_templates,
|
||||
layers.project_dir(project["root"]),
|
||||
target,
|
||||
source_layer="project",
|
||||
kind=body.종류,
|
||||
name=body.이름,
|
||||
source_ref={"project_id": project_id, "프로젝트": project.get("name")},
|
||||
)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if not done:
|
||||
raise HTTPException(status_code=404, detail="프로젝트 작업본에 그 양식이 없습니다.")
|
||||
return {"저장": done, "to": body.to}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/sources")
|
||||
async def list_sources(
|
||||
project_id: str,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""가져올 수 있는 것 — 시스템 · 회사 공식 · 같은 회사 사람 개인 · 같은 회사 다른 프로젝트."""
|
||||
project = await _project(session, project_id)
|
||||
company_id = int(project["company_id"])
|
||||
|
||||
def _people(users: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for user in users:
|
||||
found = layers.list_templates(layers.personal_dir(company_id, user["user_id"]))
|
||||
if found:
|
||||
rows.append({**user, "양식": found})
|
||||
return rows
|
||||
|
||||
def _projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for other in projects:
|
||||
if other["project_id"] == str(project_id) or not other.get("storage_path"):
|
||||
continue
|
||||
try:
|
||||
root = Path(resolve_stored_project_path(other["storage_path"]))
|
||||
except ValueError:
|
||||
continue
|
||||
found = layers.list_templates(layers.project_dir(root))
|
||||
if found:
|
||||
rows.append(
|
||||
{"project_id": other["project_id"], "name": other["name"], "양식": found}
|
||||
)
|
||||
return rows
|
||||
|
||||
users = await _company_users(company_id)
|
||||
projects = await _company_projects(company_id)
|
||||
return {
|
||||
"system": await asyncio.to_thread(layers.list_templates, layers.system_dir()),
|
||||
"company": await asyncio.to_thread(layers.list_templates, layers.company_dir(company_id)),
|
||||
"personal": await asyncio.to_thread(_people, users),
|
||||
"project": await asyncio.to_thread(_projects, projects),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/tables/{name}/filled")
|
||||
async def filled_table(
|
||||
project_id: str,
|
||||
name: str,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""설계값을 채운 표 문서 — 저장 안 함(5장 ③ 서버 단독) · `결과` = 계산 열(없으면 null)."""
|
||||
project = await _project(session, project_id)
|
||||
try:
|
||||
found = await asyncio.to_thread(
|
||||
layers.read_template, layers.project_dir(project["root"]), "table", name
|
||||
)
|
||||
if found is None:
|
||||
found = await asyncio.to_thread(
|
||||
layers.read_template, layers.system_dir(), "table", name
|
||||
)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id))
|
||||
document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths)
|
||||
result = await asyncio.to_thread(fill.recalc, document)
|
||||
return {"이름": name, "판": found["판"], "문서": document, "결과": result}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{table,drawing}/<이름>.json`).
|
||||
|
||||
M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기.
|
||||
권한은 등록하는 쪽(`main.py`)이 붙임.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class StoreError(Exception):
|
||||
def __init__(self, status: int, detail):
|
||||
super().__init__(detail)
|
||||
self.status, self.detail = status, detail
|
||||
|
||||
|
||||
def version_of(raw: bytes) -> str:
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def _path(kind: str, name: str) -> Path:
|
||||
if kind not in KINDS:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name):
|
||||
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
|
||||
return FOLDER / kind / f"{name}.json"
|
||||
|
||||
|
||||
def _info(kind: str, path: Path) -> dict[str, Any]:
|
||||
raw = path.read_bytes()
|
||||
stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
|
||||
return {"종류": kind, "이름": path.stem, "판": version_of(raw), "수정일": stamp}
|
||||
|
||||
|
||||
def list_all() -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
_info(kind, p)
|
||||
for kind in KINDS
|
||||
for p in sorted((FOLDER / kind).glob("*.json"))
|
||||
if not p.name.startswith(".")
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def read(kind: str, name: str) -> dict[str, Any]:
|
||||
path = _path(kind, name)
|
||||
if not path.is_file():
|
||||
raise StoreError(404, f"없는 양식 「{name}」")
|
||||
raw = path.read_bytes()
|
||||
return {"종류": kind, "이름": name, "판": version_of(raw), "문서": json.loads(raw)}
|
||||
|
||||
|
||||
def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
"""빈 문서·뼈대 없는 문서는 거절 — 표는 열 목록 · 도면은 entities 목록."""
|
||||
key = "열" if kind == "table" else "entities"
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
||||
raise StoreError(400, f"양식 문서에 「{key}」 목록이 없음 — 저장하지 않음")
|
||||
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise StoreError(409, {"stale": [name], "판": have})
|
||||
atomic_write_json(path, doc)
|
||||
return _info(kind, path)
|
||||
|
||||
|
||||
def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
path = _path(kind, name)
|
||||
with _LOCK:
|
||||
if not path.is_file():
|
||||
raise StoreError(404, f"없는 양식 「{name}」")
|
||||
if version is not None and version_of(path.read_bytes()) != version:
|
||||
raise StoreError(409, {"stale": [name], "판": version_of(path.read_bytes())})
|
||||
path.unlink()
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Main.ts
|
||||
* 마스터 템플릿 메인 칸 — 양식 한 개를 읽어 표·도면 편집 부품에 붙임(계약 `6_계약.md` 화면 부품).
|
||||
* 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글.
|
||||
* ========================================================================== */
|
||||
|
||||
import { setLeaveGuard } from "../A00_Common/router";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readFilled,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
projectId: string | null;
|
||||
kind: Kind;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
getDoc: () => unknown | Promise<unknown>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
type Mods = Record<string, () => Promise<unknown>>;
|
||||
// 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음
|
||||
const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts");
|
||||
const DRAWING_MODS: Mods = import.meta.glob("./M02_MasterTemplete_Drawing*.ts");
|
||||
|
||||
async function findExport<T>(mods: Mods, name: string): Promise<T | null> {
|
||||
for (const load of Object.values(mods)) {
|
||||
try {
|
||||
const mod = (await load()) as Record<string, unknown>;
|
||||
if (typeof mod[name] === "function") return mod[name] as T;
|
||||
} catch {
|
||||
// 못 읽는 파일은 건너뜀 — 페이지는 뜸
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface MainHandle {
|
||||
root: HTMLElement;
|
||||
open: (sel: Selection | null) => Promise<void>;
|
||||
current: () => Selection | null;
|
||||
/** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
|
||||
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
|
||||
return layer === "project" || layer === "personal" || (layer === "system" && isAdmin);
|
||||
}
|
||||
|
||||
export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
const root = el("div", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
|
||||
notice,
|
||||
host,
|
||||
],
|
||||
});
|
||||
|
||||
let sel: Selection | null = null;
|
||||
let version = "";
|
||||
let loaded: unknown = null;
|
||||
let editor: EditorHandle | null = null;
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
|
||||
const guard = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", guard);
|
||||
const confirmLeave = async (): Promise<boolean> => {
|
||||
if (!dirty) return true;
|
||||
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
|
||||
dirty = false;
|
||||
return true;
|
||||
};
|
||||
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
|
||||
|
||||
const showNotice = (nodes: (HTMLElement | string)[]): void => {
|
||||
notice.replaceChildren(...nodes);
|
||||
notice.hidden = nodes.length === 0;
|
||||
};
|
||||
const empty = (text: string): void => {
|
||||
host.replaceChildren(el("p", { className: "m02-main__empty", text }));
|
||||
};
|
||||
const bar = (): void => {
|
||||
reload.hidden = !sel;
|
||||
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
|
||||
};
|
||||
|
||||
const drop = (): void => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
slot.replaceChildren();
|
||||
};
|
||||
|
||||
async function mount(doc: unknown, at: Selection): Promise<void> {
|
||||
const readOnly = !canEdit(at.layer, isAdmin);
|
||||
host.replaceChildren();
|
||||
if (at.kind === "table") {
|
||||
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
SHEET_MODS,
|
||||
"createSheet",
|
||||
);
|
||||
if (create) {
|
||||
editor = create(host, doc, {
|
||||
mode: at.layer === "project" ? "project" : "master",
|
||||
onChange: () => (dirty = !readOnly),
|
||||
});
|
||||
return;
|
||||
}
|
||||
return empty(L("M02_NoSheet"));
|
||||
}
|
||||
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
DRAWING_MODS,
|
||||
"mountDrawingTemplate",
|
||||
);
|
||||
if (mountDrawing) {
|
||||
editor = mountDrawing(host, doc, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly,
|
||||
name: at.name,
|
||||
layer: at.layer,
|
||||
projectId: at.projectId,
|
||||
onChanged: (d: boolean) => (dirty = d && !readOnly),
|
||||
headerSlot: slot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
empty(L("M02_NoDrawing"));
|
||||
}
|
||||
|
||||
async function open(next: Selection | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
dirty = false;
|
||||
drop();
|
||||
showNotice([]);
|
||||
sel = next;
|
||||
bar();
|
||||
if (!next) {
|
||||
title.textContent = L("M02_PickTemplate");
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = next.name;
|
||||
try {
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
||||
if (mine !== seq) return;
|
||||
version = got.판 ?? "";
|
||||
loaded = got.문서;
|
||||
// 프로젝트 층 표 — 작업본 판은 그대로 두고 보이는 문서만 설계값으로 채움(서버가 채움 · 저장 안 함)
|
||||
const shown =
|
||||
next.layer === "project" && next.kind === "table" && next.projectId
|
||||
? await readFilled(next.projectId, next.name).then(
|
||||
(f) => f.문서,
|
||||
() => got.문서,
|
||||
)
|
||||
: got.문서;
|
||||
if (mine !== seq) return;
|
||||
await mount(shown, next);
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
empty("");
|
||||
const why = error instanceof Error ? error.message : "";
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
if (!sel) return;
|
||||
const at = sel;
|
||||
try {
|
||||
const doc = editor ? await editor.getDoc() : loaded;
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
dirty = false;
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
onSaved?.(); // 좌측 목록의 판을 새 판으로
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
const again = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
again.addEventListener("click", () => void open(at));
|
||||
showNotice([L("M02_Stale"), again]);
|
||||
return;
|
||||
}
|
||||
const why = error instanceof Error ? error.message : "";
|
||||
showToast(L("M02_SaveFailed").replace("{value}", why), "error");
|
||||
}
|
||||
}
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
reload.addEventListener("click", async () => {
|
||||
if (sel && (await confirmLeave())) void open(sel);
|
||||
});
|
||||
bar();
|
||||
empty("");
|
||||
return { root, open, current: () => sel, confirmLeave };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Page.ts
|
||||
* 마스터 템플릿 화면 — 좌측 도킹 패널(시스템 양식 목록) / 우측 편집 칸(표·도면 부품)
|
||||
*
|
||||
* 화면은 로그인만 확인 — 시스템 층 고치기는 시스템 관리자일 때만 켬(서버도 막음).
|
||||
* ========================================================================== */
|
||||
|
||||
import "@ui/ui_template_workflow_layout.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { createMain } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<void> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const isAdmin = user?.role === "SYSTEM_ADMIN";
|
||||
const main = createMain(isAdmin, () => void side.refresh());
|
||||
const side = buildSide({
|
||||
isAdmin,
|
||||
onOpen: (sel) => void main.open(sel),
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: L("M02_Title"),
|
||||
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
|
||||
showProjectName: false,
|
||||
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
layout.append(
|
||||
el("div", {
|
||||
className: "ui-workflow-layout__body",
|
||||
children: [el("main", { className: "ui-workflow-layout__main", children: [main.root] })],
|
||||
}),
|
||||
overlays.root,
|
||||
);
|
||||
root.innerHTML = "";
|
||||
root.append(layout);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면) · 새로 · 본떠 · 지우기. 서버 길은 계약 `6_계약.md`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
deleteTemplate,
|
||||
listTemplates,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
{ kind: "drawing", label: () => L("M02_KindDrawing") },
|
||||
];
|
||||
/** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */
|
||||
function stubDoc(kind: Kind, name: string): unknown {
|
||||
return kind === "table"
|
||||
? {
|
||||
양식: name,
|
||||
종류: "표",
|
||||
판: 1,
|
||||
층: ["종류", "공법", "규격"],
|
||||
열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }],
|
||||
줄: [],
|
||||
합계줄: [],
|
||||
보기: {},
|
||||
}
|
||||
: { format: 6, entities: [] };
|
||||
}
|
||||
|
||||
export interface SideOptions {
|
||||
isAdmin: boolean;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
root: HTMLElement;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
const badName = (name: string): boolean =>
|
||||
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
|
||||
export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layer = "system" as const;
|
||||
let items: TemplateInfo[] = [];
|
||||
let seq = 0;
|
||||
|
||||
const listHost = el("div", { className: "m02-side__lists" });
|
||||
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" });
|
||||
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" });
|
||||
const editRow = el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] });
|
||||
|
||||
const root = el("div", { className: "m02-side", children: [listHost, editRow] });
|
||||
|
||||
const isOpen = (info: TemplateInfo): boolean => {
|
||||
const cur = opt.getOpen();
|
||||
return !!cur && cur.layer === layer && cur.kind === info.종류 && cur.name === info.이름;
|
||||
};
|
||||
|
||||
const currentInfo = (): TemplateInfo | undefined => {
|
||||
const cur = opt.getOpen();
|
||||
return cur ? items.find((i) => i.종류 === cur.kind && i.이름 === cur.name) : undefined;
|
||||
};
|
||||
|
||||
function paintList(): void {
|
||||
listHost.replaceChildren(
|
||||
...KINDS.map(({ kind, label }) => {
|
||||
const mine = items.filter((i) => i.종류 === kind);
|
||||
return el("div", {
|
||||
className: "m02-side__group",
|
||||
children: [
|
||||
el("h4", { text: label() }),
|
||||
...(mine.length
|
||||
? mine.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item${isOpen(info) ? " is-active" : ""}`,
|
||||
text: info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paintButtons(): void {
|
||||
const editable = canEdit(layer, opt.isAdmin);
|
||||
btnNew.disabled = !editable;
|
||||
btnCopy.disabled = !editable || !currentInfo();
|
||||
btnDel.disabled = !editable || !currentInfo();
|
||||
}
|
||||
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
paintList();
|
||||
paintButtons();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
paintButtons();
|
||||
try {
|
||||
items = await listTemplates(layer, null);
|
||||
} catch (error) {
|
||||
items = [];
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기 --- */
|
||||
const nameDialog = (
|
||||
title: string,
|
||||
withKind: boolean,
|
||||
onOk: (kind: Kind, name: string) => Promise<void>,
|
||||
): void => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
const kind = createSelectField({
|
||||
label: L("M02_Kind"),
|
||||
options: KINDS.map((k) => ({ value: k.kind, text: k.label() })),
|
||||
});
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (body, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
if (badName(text)) return showToast(L("M02_BadName"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(kind.select.value as Kind, text);
|
||||
close();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
|
||||
else failed(error);
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
body.append(...(withKind ? [kind.root] : []), name.root, ok);
|
||||
name.input.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCreated = async (kind: Kind, name: string): Promise<void> => {
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
const info = items.find((i) => i.종류 === kind && i.이름 === name);
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
btnNew.addEventListener("click", () =>
|
||||
nameDialog(L("M02_NewTitle"), true, async (kind, name) => {
|
||||
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
|
||||
await openCreated(kind, name);
|
||||
}),
|
||||
);
|
||||
btnCopy.addEventListener("click", () => {
|
||||
const from = currentInfo();
|
||||
if (!from) return;
|
||||
nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => {
|
||||
const got = await readTemplate("system", from.종류, from.이름, null);
|
||||
await saveTemplate("system", from.종류, name, null, "", got.문서);
|
||||
await openCreated(from.종류, name);
|
||||
});
|
||||
});
|
||||
btnDel.addEventListener("click", async () => {
|
||||
await refresh(); // 저장 직후여도 최신 판으로
|
||||
const info = currentInfo();
|
||||
if (!info) return;
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
|
||||
try {
|
||||
await deleteTemplate(info.종류, info.이름, info.판);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
});
|
||||
|
||||
void refresh();
|
||||
return { root, refresh };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* M02 마스터 템플릿 — 좌측 패널 + 메인. 색·간격은 공용 토큰. */
|
||||
.m02-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-side__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-side__group h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-side__item {
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-side__item:hover {
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.m02-side__item.is-active {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-side__empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-side__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
}
|
||||
|
||||
.m02-main__head {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-main__title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 3rem;
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-main__head .ui-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-main__slot {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-main__notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas));
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-main__host {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.m02-main__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-24);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m02-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
"""M02 표 양식 설계값 채우기 — 서버 단독(CLAUDE.md 5장 ③) · 저장 안 함.
|
||||
|
||||
프로젝트 설계 정본 셋을 읽어 표 문서의 **바인딩 열**을 채움:
|
||||
`B05_Profile/route/structures.json` 놓인 구조물
|
||||
`.../drainage/edits/pipe_points.json` 계곡 통과 시설(관 · BOX · 물넘이 · 세월교 · 기슭막이)
|
||||
B06 횡단 설계 `design.pipe_length_m` 관 연장(부르는 쪽이 DB 에서 읽어 넘김)
|
||||
|
||||
- 펼침 열 — 마스터 한 칸 → 설계에 쓰인 값마다 열(choices 차례) · 쓰인 값이 없으면 열을 뺌.
|
||||
열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로 · 빈 값은 `-`).
|
||||
한 묶음 열은 값마다 한데 모음 · 계산 열은 `펼침틀` 로 묶음을 따라 나뉨(바인딩 없음).
|
||||
- 줄 — 측점마다 한 줄(같은 측점 구조물은 한 줄) · 점 `NO.x+y` · 구간 `NO.a~NO.b` ·
|
||||
같은 측점에 관이 둘이면 줄을 나눔 · 줄 id 는 측점에서 지어 손 값이 다시 채워도 붙어 있음.
|
||||
- 손 열 값 · 전구간 줄 · 사용자가 더한 줄(`손`)은 작업본 것을 그대로 지킴.
|
||||
- 값을 여기서 짓지 않음 — 정본 값을 모으기만. 빈 값은 빈칸(0 으로 안 채움).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureType, structure_type_map
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
|
||||
#: 관 측점 ↔ 횡단 측점 맞추기 허용(옛 배수관 물량과 같은 규칙) — 넓히면 옆 관 연장을 물어 옴.
|
||||
PIPE_MATCH_TOLERANCE_M = 0.5
|
||||
#: 계곡 통과 시설 `facility` → 종류(빈 값은 관).
|
||||
FACILITY_TYPES = {
|
||||
"pipe": "pipe",
|
||||
"box_culvert": "box_culvert",
|
||||
"ford_pavement": "ford_pavement",
|
||||
"ford_bridge": "ford_bridge",
|
||||
"revetment": "revetment",
|
||||
}
|
||||
EMPTY_LABEL = "-" # 열 id 안 빈 값(저장본과 맞춤) — 머리 글은 `빈값`
|
||||
EMPTY_TEXT = "미입력"
|
||||
#: 계산 열이 설계값 열 묶음을 따라 나뉠 때 쓰는 틀(바인딩 아님 — 표 부품이 설계값으로 안 봄).
|
||||
FRAME_KEY = "펼침틀"
|
||||
ORPHAN_NOTE = "설계에서 측점 없어짐"
|
||||
#: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀.
|
||||
RULE_COLUMNS_KEY = "양식열"
|
||||
_REF = re.compile(r"\[([^\]$@][^\]@]*)\]")
|
||||
|
||||
|
||||
# ── 설계 읽기 ─────────────────────────────────────────
|
||||
|
||||
|
||||
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
|
||||
"""B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음."""
|
||||
found: dict[float, float] = {}
|
||||
for row in designs or []:
|
||||
design = row.get("design") if isinstance(row, dict) else None
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
length = _number(design.get("pipe_length_m"))
|
||||
if length and length > 0:
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = length
|
||||
return found
|
||||
|
||||
|
||||
def _nearest(lengths: dict[float, float], chainage: float) -> float | None:
|
||||
if not lengths:
|
||||
return None
|
||||
key = round(chainage, 3)
|
||||
if key in lengths:
|
||||
return lengths[key]
|
||||
best = min(lengths, key=lambda x: abs(x - chainage))
|
||||
return lengths[best] if abs(best - chainage) <= PIPE_MATCH_TOLERANCE_M else None
|
||||
|
||||
|
||||
def _with_defaults(definition: StructureType | None, options: dict[str, Any]) -> dict[str, Any]:
|
||||
"""빠진 칸은 레지스트리 기본값 — B05 화면이 보여 주는 값과 같게."""
|
||||
merged = dict(options or {})
|
||||
for field in definition.options if definition else []:
|
||||
if field.key not in merged and field.default is not None:
|
||||
merged[field.key] = field.default
|
||||
return merged
|
||||
|
||||
|
||||
def collect_items(
|
||||
project_root: str | Path,
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
types: dict[str, StructureType] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""설계 정본 → 구조물 목록.
|
||||
|
||||
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장}`.
|
||||
"""
|
||||
types = types or structure_type_map()
|
||||
lengths = pipe_lengths or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
_, structures = load_structures(str(project_root))
|
||||
for item in structures:
|
||||
definition = types.get(item.type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
items.append(
|
||||
{
|
||||
"type_id": item.type_id,
|
||||
"placement": definition.placement if definition else "point",
|
||||
"chainage_m": float(item.chainage_m),
|
||||
"start_m": item.start_m,
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
}
|
||||
)
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
type_id = FACILITY_TYPES.get(point.facility or "pipe", point.facility)
|
||||
chainage = float(point.chainage_m)
|
||||
items.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
"placement": "point",
|
||||
"chainage_m": chainage,
|
||||
"start_m": None,
|
||||
"end_m": None,
|
||||
"options": _with_defaults(types.get(type_id), dict(point.options or {})),
|
||||
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
# ── 값 · 글 ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _label(key: str, value: Any) -> str:
|
||||
"""펼친 값 글 — 높이 등 `_m` 칸은 `2.0` 처럼 소수 한 자리는 남김."""
|
||||
if value is None or value == "":
|
||||
return EMPTY_LABEL
|
||||
number = _number(value)
|
||||
if number is None or isinstance(value, str) and not key.endswith("_m"):
|
||||
return str(value)
|
||||
if number.is_integer():
|
||||
return f"{number:.1f}" if key.endswith("_m") else str(int(number))
|
||||
return f"{number:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _length(item: dict[str, Any]) -> float | None:
|
||||
stated = _number(item["options"].get("length_m"))
|
||||
if stated:
|
||||
return stated
|
||||
start, end = _number(item.get("start_m")), _number(item.get("end_m"))
|
||||
return abs(end - start) if start is not None and end is not None else None
|
||||
|
||||
|
||||
def _source_value(source: dict[str, Any], item: dict[str, Any]) -> float | None:
|
||||
kind = source.get("값")
|
||||
if kind == "개소":
|
||||
return 1.0
|
||||
if kind == "길이":
|
||||
return _length(item)
|
||||
if kind == "관연장":
|
||||
return item.get("관연장")
|
||||
if not kind or kind == "식":
|
||||
return None
|
||||
return _number(item["options"].get(kind))
|
||||
|
||||
|
||||
def _matches(source: dict[str, Any], item: dict[str, Any]) -> bool:
|
||||
if source.get("종류") != item["type_id"]:
|
||||
return False
|
||||
for key, want in (source.get("조건") or {}).items():
|
||||
if str(item["options"].get(key)) != str(want):
|
||||
return False
|
||||
# 펼칠 칸이 모두 비면 그 시설이 없는 것(예: 날개벽 형식 없음 → 관보호공 없음)
|
||||
keys = [key for key in source.get("펼침") or [] if not key.startswith("=")]
|
||||
return not keys or any(item["options"].get(key) not in (None, "") for key in keys)
|
||||
|
||||
|
||||
def _sources(binding: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
main = {key: binding.get(key) for key in ("종류", "펼침", "값", "조건")}
|
||||
return [main, *(binding.get("더함") or [])]
|
||||
|
||||
|
||||
def _expansion(source: dict[str, Any], item: dict[str, Any]) -> tuple[str, ...]:
|
||||
values = []
|
||||
for key in source.get("펼침") or []:
|
||||
values.append(key[1:] if key.startswith("=") else _label(key, item["options"].get(key)))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _position_orders(binding: dict[str, Any], choices: dict[str, list[str]]) -> list[list[str]]:
|
||||
"""펼침 자리마다 차례 목록 — 고정 글(`=찰쌓기`)은 출처 차례 · 칸은 choices 차례."""
|
||||
orders: list[list[str]] = []
|
||||
for source in _sources(binding):
|
||||
for index, key in enumerate(source.get("펼침") or []):
|
||||
while len(orders) <= index:
|
||||
orders.append([])
|
||||
listed = [key[1:]] if key.startswith("=") else choices.get(key, [])
|
||||
orders[index].extend(value for value in listed if value not in orders[index])
|
||||
return orders
|
||||
|
||||
|
||||
def _order_key(values: tuple[str, ...], orders: list[list[str]]) -> tuple[Any, ...]:
|
||||
"""차례 목록 → 수 차례 → 글 차례 · 빈 값(-)은 끝."""
|
||||
out: list[Any] = []
|
||||
for index, value in enumerate(values):
|
||||
listed = orders[index] if index < len(orders) else []
|
||||
number = _number(value)
|
||||
out.append(
|
||||
(
|
||||
value == EMPTY_LABEL,
|
||||
listed.index(value) if value in listed else len(listed),
|
||||
number if number is not None else float("inf"),
|
||||
value,
|
||||
)
|
||||
)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# ── 측점 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def station_label(chainage_m: float, interval_m: float = 20.0) -> str:
|
||||
"""`NO.12` · `NO.12+5` · `NO.12+5.5` — 실무 구조물위치 표기."""
|
||||
safe = interval_m if interval_m > 0 else 20.0
|
||||
number = int((chainage_m + 1e-6) // safe)
|
||||
remainder = round(chainage_m - number * safe, 2)
|
||||
if remainder >= safe - 0.005:
|
||||
number, remainder = number + 1, 0.0
|
||||
if abs(remainder) < 0.005:
|
||||
return f"NO.{number}"
|
||||
text = f"{remainder:.2f}".rstrip("0").rstrip(".")
|
||||
return f"NO.{number}+{text}"
|
||||
|
||||
|
||||
def _span(item: dict[str, Any]) -> tuple[float, float | None]:
|
||||
start, end = _number(item.get("start_m")), _number(item.get("end_m"))
|
||||
if item["placement"] == "interval" and start is not None and end is not None:
|
||||
low, high = sorted((start, end))
|
||||
if high - low > 1e-6:
|
||||
return round(low, 2), round(high, 2)
|
||||
return round(float(item["chainage_m"]), 2), None
|
||||
|
||||
|
||||
def _row_id(start: float, end: float | None, index: int) -> str:
|
||||
text = f"s{start:.2f}" + (f"~{end:.2f}" if end is not None else "")
|
||||
return text + (f"#{index}" if index else "")
|
||||
|
||||
|
||||
# ── 채우기 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _choices(types: dict[str, StructureType]) -> dict[str, list[str]]:
|
||||
found: dict[str, list[str]] = {}
|
||||
for definition in types.values():
|
||||
for field in definition.options:
|
||||
if field.choices and field.key not in found:
|
||||
found[field.key] = list(field.choices)
|
||||
return found
|
||||
|
||||
|
||||
def _rewrite(formula: str, siblings: set[str], suffix: str) -> str:
|
||||
return _REF.sub(
|
||||
lambda m: f"[{m.group(1)}|{suffix}]" if m.group(1) in siblings else m.group(0), formula
|
||||
)
|
||||
|
||||
|
||||
def _blank_vars(formula: str, variables: dict[str, Any]) -> list[str]:
|
||||
names = re.findall(r"\[\$([^\]]+)\]", formula)
|
||||
return [name for name in names if variables.get(name) in (None, "")]
|
||||
|
||||
|
||||
def _frame(column: dict[str, Any]) -> dict[str, Any]:
|
||||
"""펼침 틀 — 설계값 열은 `바인딩` · 따라 나뉘는 계산 열은 `펼침틀`(설계값 딱지 없음)."""
|
||||
return column.get("바인딩") or column.get(FRAME_KEY) or {}
|
||||
|
||||
|
||||
def _group(column: dict[str, Any]) -> str:
|
||||
return _frame(column).get("묶음") or column["id"]
|
||||
|
||||
|
||||
def _head_part(frame: dict[str, Any], index: int, value: str) -> str:
|
||||
"""펼친 값 한 자리 머리 글 — `값꼴`(`H={}`) · 빈 값은 `빈값`(`높이 미입력`)."""
|
||||
if value == EMPTY_LABEL:
|
||||
empties = frame.get("빈값") or []
|
||||
return (empties[index] if index < len(empties) else "") or EMPTY_TEXT
|
||||
shapes = frame.get("값꼴") or []
|
||||
shape = (shapes[index] if index < len(shapes) else "") or "{}"
|
||||
return shape.replace("{}", str((frame.get("이름") or {}).get(value, value)))
|
||||
|
||||
|
||||
def _unexpand(columns: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""채운 표가 작업본으로 저장됐으면 펼친 열을 마스터 칸(`원열`)으로 되돌림 — 두 번 안 펼침."""
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for column in columns:
|
||||
origin = _frame(column).get("원열")
|
||||
if origin is None:
|
||||
out.append(column)
|
||||
elif origin["id"] not in seen:
|
||||
seen.add(origin["id"])
|
||||
out.append(origin)
|
||||
return out
|
||||
|
||||
|
||||
def _expand_columns(
|
||||
columns: list[dict[str, Any]],
|
||||
items: list[dict[str, Any]],
|
||||
variables: dict[str, Any],
|
||||
choices: dict[str, list[str]],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]:
|
||||
"""펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
|
||||
|
||||
돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.
|
||||
"""
|
||||
columns = _unexpand(columns)
|
||||
groups: dict[str, set[tuple[str, ...]]] = {}
|
||||
orders_of: dict[str, list[list[str]]] = {}
|
||||
members: dict[str, list[dict[str, Any]]] = {}
|
||||
for column in columns:
|
||||
if not column.get("펼침") or not _frame(column):
|
||||
continue
|
||||
members.setdefault(_group(column), []).append(column)
|
||||
binding = column.get("바인딩")
|
||||
if not binding:
|
||||
continue # 계산 열 — 설계값 열이 연 값을 따름
|
||||
orders_of.setdefault(_group(column), _position_orders(binding, choices))
|
||||
found = groups.setdefault(_group(column), set())
|
||||
for source in _sources(binding):
|
||||
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
|
||||
found.update(_expansion(source, item) for item in items if _matches(source, item))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
||||
for column in columns:
|
||||
if not column.get("펼침") or not _frame(column):
|
||||
out.append(copy.deepcopy(column))
|
||||
continue
|
||||
group = _group(column)
|
||||
if members[group][0] is not column:
|
||||
continue # 묶음 첫 열 자리에서 값마다 묶음 열을 한데 냄
|
||||
orders = orders_of.get(group, [])
|
||||
sibling_ids = {member["id"] for member in members[group]}
|
||||
for values in sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders)):
|
||||
for member in members[group]:
|
||||
new = _expand_one(member, values, sibling_ids, variables)
|
||||
out.append(new)
|
||||
made.setdefault(member["id"], []).append((new["id"], "|".join(values), new))
|
||||
return out, made
|
||||
|
||||
|
||||
def _expand_one(
|
||||
column: dict[str, Any],
|
||||
values: tuple[str, ...],
|
||||
siblings: set[str],
|
||||
variables: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
frame = _frame(column)
|
||||
suffix = "|".join(values)
|
||||
shown = [_head_part(frame, index, value) for index, value in enumerate(values)]
|
||||
new = copy.deepcopy(column)
|
||||
new["id"] = f"{column['id']}|{suffix}"
|
||||
new["펼침"] = False
|
||||
new["머리"] = [
|
||||
None if part is None else part.format(*shown)
|
||||
for part in (frame.get("머리틀") or column["머리"])
|
||||
]
|
||||
key = "바인딩" if column.get("바인딩") else FRAME_KEY
|
||||
new[key] = {**frame, "펼친값": list(values), "원열": copy.deepcopy(column)}
|
||||
template = frame.get("식틀")
|
||||
used = [int(i) for i in re.findall(r"\{(\d+)\}", template or "")]
|
||||
blank = ""
|
||||
if any(values[i] == EMPTY_LABEL for i in used if i < len(values)):
|
||||
formula, blank = None, "설계값이"
|
||||
else:
|
||||
formula = template.format(*values) if template else column.get("식")
|
||||
if formula:
|
||||
formula = _rewrite(formula, siblings, suffix)
|
||||
blank = "변수가" if _blank_vars(formula, variables) else ""
|
||||
if blank:
|
||||
new.pop("식", None)
|
||||
new["설명"] = f"{column.get('설명', '')} · {blank} 빈칸이라 계산 안 함".strip()
|
||||
elif formula:
|
||||
new["식"] = formula
|
||||
return new
|
||||
|
||||
|
||||
def fill_document(
|
||||
document: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
types: dict[str, StructureType] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본)."""
|
||||
types = types or structure_type_map()
|
||||
doc = copy.deepcopy(document)
|
||||
variables = dict(doc.get("변수") or {})
|
||||
interval = _number(variables.get("측점간격_m")) or 20.0
|
||||
rule_columns = _unexpand(doc.get("열") or [])
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types))
|
||||
notes: list[str] = []
|
||||
|
||||
# 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔)
|
||||
slots: dict[tuple[float, float | None, int], dict[str, Any]] = {}
|
||||
pipes_at: dict[tuple[float, float | None], int] = {}
|
||||
item_slot: list[tuple[float, float | None, int]] = []
|
||||
for item in items:
|
||||
start, end = _span(item)
|
||||
index = 0
|
||||
if item["type_id"] == "pipe":
|
||||
index = pipes_at.get((start, end), 0)
|
||||
pipes_at[(start, end)] = index + 1
|
||||
slot = (start, end, index)
|
||||
slots.setdefault(slot, {"id": _row_id(*slot), "값": {}})
|
||||
item_slot.append(slot)
|
||||
|
||||
for column in columns:
|
||||
binding = column.get("바인딩") or {}
|
||||
if not binding or column.get("손"):
|
||||
continue
|
||||
expanded = binding.get("펼친값")
|
||||
for source in _sources(binding):
|
||||
if source.get("값") in (None, "식"):
|
||||
continue
|
||||
for item, slot in zip(items, item_slot, strict=True):
|
||||
if not _matches(source, item):
|
||||
continue
|
||||
if expanded is not None and list(_expansion(source, item)) != expanded:
|
||||
continue
|
||||
value = _source_value(source, item)
|
||||
if value is None:
|
||||
continue
|
||||
cells = slots[slot]["값"]
|
||||
cells[column["id"]] = round((cells.get(column["id"]) or 0) + value, 3)
|
||||
|
||||
# 포장 줄눈 간격이 변수와 다르면 그 줄 식에 박음(줄 식이 열 식을 이김)
|
||||
base_spacing = _number(variables.get("수축줄눈_간격_m"))
|
||||
for item, slot in zip(items, item_slot, strict=True):
|
||||
spacing = _number(item["options"].get("joint_spacing_m"))
|
||||
if item["type_id"] != "pavement_concrete" or spacing in (None, base_spacing):
|
||||
continue
|
||||
for new_id, suffix, new in made.get("pv_jt", []):
|
||||
if "|".join(_expansion({"펼침": ["thickness_cm"]}, item)) == suffix:
|
||||
text = new.get("식", "").replace("[$수축줄눈_간격_m]", _label("", spacing))
|
||||
slots[slot].setdefault("식", {})[new_id] = text
|
||||
|
||||
missing = sum(1 for item in items if item["type_id"] == "pipe" and not item.get("관연장"))
|
||||
if missing:
|
||||
notes.append(f"관 연장 없음 {missing}곳 — B06 횡단 설계 전이면 빈칸")
|
||||
loose = sum(
|
||||
1 for item in items if item["type_id"] == "guardrail" and not item["options"].get("kind")
|
||||
)
|
||||
if loose:
|
||||
notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈")
|
||||
|
||||
# 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄
|
||||
hand = _hand_ids(columns)
|
||||
old_rows = {row.get("id"): row for row in doc.get("줄") or []}
|
||||
fixed = [row for row in doc.get("줄") or [] if row.get("고정")]
|
||||
added = [row for row in doc.get("줄") or [] if row.get("손") and not row.get("고정")]
|
||||
design_rows = []
|
||||
for number, slot in enumerate(sorted(slots), start=1):
|
||||
row = slots[slot]
|
||||
start, end, _ = slot
|
||||
label = station_label(start, interval)
|
||||
if end is not None:
|
||||
label = f"{label}~{station_label(end, interval)}"
|
||||
row["값"].update({"no": str(number), "sta": label})
|
||||
previous = old_rows.get(row["id"]) or {}
|
||||
for key, value in (previous.get("값") or {}).items():
|
||||
if key in hand:
|
||||
row["값"][key] = value
|
||||
design_rows.append(row)
|
||||
# 설계에서 빠진 줄의 손 값은 버리지 않음 — 사용자 줄로 남겨 보이게
|
||||
kept = {row["id"] for row in design_rows}
|
||||
for row in doc.get("줄") or []:
|
||||
if row.get("고정") or row.get("손") or row.get("id") in kept:
|
||||
continue
|
||||
values = {
|
||||
k: v for k, v in (row.get("값") or {}).items() if k in hand and v not in (None, "")
|
||||
}
|
||||
if values:
|
||||
sta = (row.get("값") or {}).get("sta")
|
||||
memo = " · ".join(filter(None, [str(values.get("memo") or ""), ORPHAN_NOTE]))
|
||||
added.append({"id": row["id"], "값": {"sta": sta, **values, "memo": memo}, "손": True})
|
||||
doc["열"] = columns
|
||||
doc["줄"] = [*fixed, *design_rows, *added]
|
||||
doc["알림"] = notes
|
||||
doc[RULE_COLUMNS_KEY] = copy.deepcopy(rule_columns) # 저장 때 설계에 안 쓰인 펼침 열도 되살림
|
||||
return doc
|
||||
|
||||
|
||||
def _hand_ids(columns: list[dict[str, Any]]) -> set[str]:
|
||||
"""사람이 적는 열 — 설계값 · 계산 열 말고(손 열 · 비고 · 더한 열) · 차례 · 측점 빼고."""
|
||||
return {
|
||||
column["id"]
|
||||
for column in columns
|
||||
if not _frame(column) and not column.get("식") and column["id"] not in ("no", "sta")
|
||||
}
|
||||
|
||||
|
||||
def _restore_rules(
|
||||
columns: list[dict[str, Any]], rules: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""채울 때 설계 값이 없어 빠졌던 펼침 열을 원래 자리(앞 열 뒤)에 되살림."""
|
||||
out = list(columns)
|
||||
for index, rule in enumerate(rules):
|
||||
if not rule.get("펼침") or any(c["id"] == rule["id"] for c in out):
|
||||
continue
|
||||
before = [r["id"] for r in rules[:index]]
|
||||
at = max((i + 1 for i, c in enumerate(out) if c["id"] in before), default=0)
|
||||
out.insert(at, copy.deepcopy(rule))
|
||||
return out
|
||||
|
||||
|
||||
def strip_design(document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""작업본에 둘 것만 — 양식(열 · 식 · 변수 · 머리) + 손 값 + 전구간 줄 · 사용자 줄.
|
||||
|
||||
채운 표가 [저장]으로 와도 설계값 · 펼친 열은 버림(펼침 규칙 열 하나로 되돌림) ·
|
||||
측점 줄은 손 값이 있을 때만 `{id(측점 키), sta, 손 값}` 으로 남김 —
|
||||
다시 채우면 같은 측점에 붙음.
|
||||
"""
|
||||
doc = copy.deepcopy(document)
|
||||
for key in ("알림", "결과"):
|
||||
doc.pop(key, None)
|
||||
columns = _restore_rules(_unexpand(doc.get("열") or []), doc.pop(RULE_COLUMNS_KEY, None) or [])
|
||||
hand = _hand_ids(columns)
|
||||
rows = []
|
||||
for row in doc.get("줄") or []:
|
||||
if row.get("고정"):
|
||||
rows.append(row)
|
||||
continue
|
||||
values = row.get("값") or {}
|
||||
kept = {k: v for k, v in values.items() if k in hand and v not in (None, "")}
|
||||
if row.get("손"):
|
||||
formulas = {k: v for k, v in (row.get("식") or {}).items() if k in hand}
|
||||
new = {**row, "값": {"sta": values.get("sta"), **kept}}
|
||||
new.pop("식", None)
|
||||
if formulas:
|
||||
new["식"] = formulas
|
||||
rows.append(new)
|
||||
elif kept:
|
||||
rows.append({"id": row["id"], "값": {"sta": values.get("sta"), **kept}})
|
||||
doc["열"] = columns
|
||||
doc["줄"] = rows
|
||||
return doc
|
||||
|
||||
|
||||
def is_fillable(document: Any) -> bool:
|
||||
"""설계값을 받는 표인가 — 바인딩 열이 하나라도 있으면."""
|
||||
return isinstance(document, dict) and any(
|
||||
isinstance(column, dict) and column.get("바인딩") for column in document.get("열") or []
|
||||
)
|
||||
|
||||
|
||||
def fill_table(
|
||||
project_root: str | Path,
|
||||
document: dict[str, Any],
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트 설계로 표 문서를 채움 — 저장 안 함."""
|
||||
types = structure_type_map()
|
||||
return fill_document(document, collect_items(project_root, pipe_lengths, types), types)
|
||||
|
||||
|
||||
def recalc(document: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""계산 열 — 표 재계산(sub3 `common_util_sheet_recalc`)이 있으면 그 결과 · 없으면 None."""
|
||||
try:
|
||||
from common_util.common_util_sheet_recalc import recalc_sheet
|
||||
except ImportError:
|
||||
return None
|
||||
return recalc_sheet(document)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
|
||||
- 판 = 파일 sha256 앞 16자(M01 Store 와 같음) · 판이 다르면 `StaleTemplate`.
|
||||
- `_initial/` 은 프로젝트 첫 복사 때만 씀 — 그 뒤 어떤 길로도 쓰지 않음(초기화는 읽기만).
|
||||
- 권한은 부르는 쪽(라우터) 몫 — 여기는 자리와 파일만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
KINDS = ("table", "drawing")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
|
||||
#: 시스템 층 뿌리 — 시험이 임시 폴더로 바꿈.
|
||||
SYSTEM_ROOT = config_system.PROJECT_ROOT / "resources" / "master_template"
|
||||
|
||||
|
||||
class StaleTemplate(Exception):
|
||||
"""저장하려는 판이 파일의 지금 판과 다름 — 라우터가 409 로 답함."""
|
||||
|
||||
def __init__(self, current: str | None) -> None:
|
||||
super().__init__("양식이 그새 바뀌었습니다.")
|
||||
self.current = current
|
||||
|
||||
|
||||
# ── 자리 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def storage_root() -> Path:
|
||||
"""`storage/` 실경로 — 부를 때마다 설정을 읽음(시험이 바꿈)."""
|
||||
return Path(config_system.STORAGE_BASE_DIR).resolve()
|
||||
|
||||
|
||||
def _inside(root: Path, path: Path) -> Path:
|
||||
path = path.resolve()
|
||||
if root.resolve() not in (path, *path.parents):
|
||||
raise ValueError("양식 자리가 저장소 루트를 벗어났습니다.")
|
||||
return path
|
||||
|
||||
|
||||
def _id(value: Any, what: str) -> str:
|
||||
text = str(value)
|
||||
if not text.isdigit():
|
||||
raise ValueError(f"{what} 식별자가 올바르지 않습니다.")
|
||||
return text
|
||||
|
||||
|
||||
def system_dir() -> Path:
|
||||
return Path(SYSTEM_ROOT)
|
||||
|
||||
|
||||
def company_dir(company_id: int | str) -> Path:
|
||||
root = storage_root()
|
||||
return _inside(root, root / _id(company_id, "회사") / TEMPLATES_DIRNAME)
|
||||
|
||||
|
||||
def personal_dir(company_id: int | str, user_id: int | str) -> Path:
|
||||
root = storage_root()
|
||||
return _inside(
|
||||
root, root / _id(company_id, "회사") / _id(user_id, "사용자") / TEMPLATES_DIRNAME
|
||||
)
|
||||
|
||||
|
||||
def project_dir(project_root: str | Path) -> Path:
|
||||
"""프로젝트 작업본 자리 — `project_root` 는 `resolve_stored_project_path` 결과."""
|
||||
return Path(project_root) / TEMPLATES_DIRNAME
|
||||
|
||||
|
||||
def initial_dir(project_root: str | Path) -> Path:
|
||||
return project_dir(project_root) / INITIAL_DIRNAME
|
||||
|
||||
|
||||
def check_kind(kind: str) -> str:
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"양식 종류는 {', '.join(KINDS)} 중 하나입니다.")
|
||||
return kind
|
||||
|
||||
|
||||
def check_name(name: str) -> str:
|
||||
text = str(name or "").strip()
|
||||
bad = not text or text != name or text.startswith((".", "_")) or len(text) > 80
|
||||
if bad or any(ch in text for ch in '/\\:*?"<>|') or ".." in text:
|
||||
raise ValueError(
|
||||
"양식 이름이 올바르지 않습니다 — 빈 이름 · 앞뒤 빈칸 · 80자 초과 · 앞머리 . _ · "
|
||||
'`/ \ .. : * ? " < > |` 는 못 씁니다.'
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def template_path(layer_dir: str | Path, kind: str, name: str) -> Path:
|
||||
base = Path(layer_dir)
|
||||
return _inside(base, base / check_kind(kind) / f"{check_name(name)}.json")
|
||||
|
||||
|
||||
# ── 읽기 · 쓰기 ───────────────────────────────────────
|
||||
|
||||
|
||||
def version_of(path: str | Path) -> str | None:
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _modified(path: Path) -> str:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
|
||||
"""층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례."""
|
||||
base = Path(layer_dir)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for kind in KINDS:
|
||||
folder = base / kind
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
if path.name.startswith((".", "_")):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"종류": kind,
|
||||
"이름": path.stem,
|
||||
"판": version_of(path),
|
||||
"수정일": _modified(path),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any] | None:
|
||||
"""`{종류, 이름, 판, 문서}` — 없으면 None."""
|
||||
path = template_path(layer_dir, kind, name)
|
||||
if not path.is_file():
|
||||
return None
|
||||
raw = path.read_bytes()
|
||||
return {
|
||||
"종류": kind,
|
||||
"이름": name,
|
||||
"판": hashlib.sha256(raw).hexdigest()[:16],
|
||||
"문서": json.loads(raw.decode("utf-8")),
|
||||
}
|
||||
|
||||
|
||||
def check_skeleton(kind: str, document: Any) -> None:
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록(빈 `{}` 저장 막기)."""
|
||||
key = {"table": "열", "drawing": "entities"}.get(kind)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
if key and not isinstance(document.get(key), list):
|
||||
raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.")
|
||||
|
||||
|
||||
def write_template(
|
||||
layer_dir: str | Path,
|
||||
kind: str,
|
||||
name: str,
|
||||
document: dict[str, Any],
|
||||
*,
|
||||
version: str | None = None,
|
||||
check_version: bool = False,
|
||||
) -> str:
|
||||
"""원자 쓰기 → 새 판. `check_version` 이면 `version` 이 지금 판과 같아야 함(새 파일은 None)."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
path = template_path(layer_dir, kind, name)
|
||||
if check_version:
|
||||
current = version_of(path)
|
||||
if current != (version or None):
|
||||
raise StaleTemplate(current)
|
||||
atomic_write_json(path, document)
|
||||
return version_of(path) or ""
|
||||
|
||||
|
||||
def delete_template(
|
||||
layer_dir: str | Path,
|
||||
kind: str,
|
||||
name: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
check_version: bool = False,
|
||||
) -> bool:
|
||||
"""양식 지움 · manifest 줄도 뺌. `check_version` 이면 지금 판과 달라 `StaleTemplate`."""
|
||||
path = template_path(layer_dir, kind, name)
|
||||
if not path.is_file():
|
||||
return False
|
||||
if check_version and version_of(path) != version:
|
||||
raise StaleTemplate(version_of(path))
|
||||
path.unlink()
|
||||
manifest = read_manifest(layer_dir)
|
||||
if manifest.pop(f"{kind}/{name}", None) is not None:
|
||||
_write_manifest(layer_dir, manifest)
|
||||
return True
|
||||
|
||||
|
||||
# ── manifest ──────────────────────────────────────────
|
||||
|
||||
|
||||
def read_manifest(layer_dir: str | Path) -> dict[str, Any]:
|
||||
path = Path(layer_dir) / MANIFEST_NAME
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data.get("양식", {}) if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_manifest(layer_dir: str | Path, entries: dict[str, Any]) -> None:
|
||||
atomic_write_json(Path(layer_dir) / MANIFEST_NAME, {"양식": entries})
|
||||
|
||||
|
||||
def note_source(
|
||||
layer_dir: str | Path, kind: str, name: str, source_layer: str, version: str
|
||||
) -> None:
|
||||
"""manifest 한 줄 — 복사가 아니라 고쳐 쓴 양식의 출처 · 판."""
|
||||
manifest = read_manifest(layer_dir)
|
||||
manifest[f"{kind}/{name}"] = _stamp(source_layer, name, version)
|
||||
_write_manifest(layer_dir, manifest)
|
||||
|
||||
|
||||
def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str, Any]:
|
||||
entry = {
|
||||
"층": layer,
|
||||
"이름": name,
|
||||
"판": version,
|
||||
"적용일": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
entry.update({key: value for key, value in extra.items() if value is not None})
|
||||
return entry
|
||||
|
||||
|
||||
# ── 복사 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def copy_templates(
|
||||
source_dir: str | Path,
|
||||
target_dir: str | Path,
|
||||
*,
|
||||
source_layer: str,
|
||||
kind: str | None = None,
|
||||
name: str | None = None,
|
||||
only_missing: bool = False,
|
||||
source_ref: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
"""`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록.
|
||||
|
||||
`kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
|
||||
manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는
|
||||
적지 않음 — 「어디서 가져왔나」 한 단계만.
|
||||
"""
|
||||
if source_layer not in LAYERS:
|
||||
raise ValueError("출처 층이 올바르지 않습니다.")
|
||||
rows = list_templates(source_dir)
|
||||
if kind is not None:
|
||||
rows = [row for row in rows if row["종류"] == check_kind(kind)]
|
||||
if name is not None:
|
||||
rows = [row for row in rows if row["이름"] == check_name(name)]
|
||||
manifest = read_manifest(target_dir)
|
||||
copied: list[str] = []
|
||||
for row in rows:
|
||||
source = template_path(source_dir, row["종류"], row["이름"])
|
||||
target = template_path(target_dir, row["종류"], row["이름"])
|
||||
if only_missing and target.exists():
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.tmp")
|
||||
shutil.copyfile(source, temporary)
|
||||
temporary.replace(target)
|
||||
key = f"{row['종류']}/{row['이름']}"
|
||||
manifest[key] = _stamp(source_layer, row["이름"], row["판"], **(source_ref or {}))
|
||||
copied.append(key)
|
||||
if copied:
|
||||
_write_manifest(target_dir, manifest)
|
||||
return copied
|
||||
|
||||
|
||||
def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dict[str, list[str]]:
|
||||
"""시스템 양식 전부를 프로젝트 작업본과 `_initial/` 로 복사.
|
||||
|
||||
프로젝트 만들 때 · 옛 프로젝트 넣기(`only_missing`) 둘 다 이 길.
|
||||
|
||||
`_initial/` 은 늘 없는 것만 더함 — 이미 있으면 절대 덮지 않음.
|
||||
"""
|
||||
return {
|
||||
"작업본": copy_templates(
|
||||
system_dir(),
|
||||
project_dir(project_root),
|
||||
source_layer="system",
|
||||
only_missing=only_missing,
|
||||
),
|
||||
"초기": copy_templates(
|
||||
system_dir(), initial_dir(project_root), source_layer="system", only_missing=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def reset_project(
|
||||
project_root: str | Path, *, kind: str | None = None, name: str | None = None
|
||||
) -> list[str]:
|
||||
"""[초기화] — `_initial/` 을 작업본에 덮어씀(`_initial/` 은 읽기만)."""
|
||||
initial = initial_dir(project_root)
|
||||
rows = list_templates(initial)
|
||||
if kind is not None:
|
||||
rows = [row for row in rows if row["종류"] == check_kind(kind)]
|
||||
if name is not None:
|
||||
rows = [row for row in rows if row["이름"] == check_name(name)]
|
||||
initial_manifest = read_manifest(initial)
|
||||
manifest = read_manifest(project_dir(project_root))
|
||||
done: list[str] = []
|
||||
for row in rows:
|
||||
key = f"{row['종류']}/{row['이름']}"
|
||||
target = template_path(project_dir(project_root), row["종류"], row["이름"])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.tmp")
|
||||
shutil.copyfile(template_path(initial, row["종류"], row["이름"]), temporary)
|
||||
temporary.replace(target)
|
||||
manifest[key] = initial_manifest.get(key) or _stamp("system", row["이름"], row["판"])
|
||||
done.append(key)
|
||||
if done:
|
||||
_write_manifest(project_dir(project_root), manifest)
|
||||
return done
|
||||
@@ -0,0 +1,173 @@
|
||||
"""이미 만든 프로젝트에 시스템 양식 넣기 — 한 번 돌리는 도구 (PLAN 10-5).
|
||||
|
||||
프로젝트마다 `seed_project(only_missing=True)` — 작업본 · `_initial/` 에 **없는 것만 더함**.
|
||||
이미 있는 양식 · 다른 파일은 안 건드림 · 폴더가 없는 프로젝트는 건너뜀(만들지 않음).
|
||||
다시 돌려도 됨 — 시스템 양식이 늘었으면 그 몫만 더해짐.
|
||||
`--refresh-tables` — 옛 표 틀(계산 열에 바인딩)만 새 판으로 · 손 값은 지킴.
|
||||
|
||||
./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py \\
|
||||
[--dry-run] [--refresh-tables] [--report 파일]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from config import config_system # noqa: E402
|
||||
from config.config_db import close_db_pool, get_db_pool, init_db_pool # noqa: E402
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill # noqa: E402
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
|
||||
|
||||
|
||||
async def _projects() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.name, p.company_id, p.user_id, p.storage_path, u.name
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.deleted_at IS NULL ORDER BY p.company_id, p.created_at"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
keys = ("id", "name", "company_id", "user_id", "storage_path", "owner")
|
||||
return [dict(zip(keys, row, strict=True)) for row in rows]
|
||||
|
||||
|
||||
def project_root(storage_path: str | None) -> Path | None:
|
||||
"""DB 저장 경로 → 실경로 · 없거나 수상하면 None(폴더를 만들지 않음)."""
|
||||
if not storage_path:
|
||||
return None
|
||||
parts = PurePosixPath(storage_path.replace("\\", "/")).parts
|
||||
if not parts or parts[0] != "storage" or ".." in parts or len(parts) < 2:
|
||||
return None
|
||||
root = Path(os.path.realpath(config_system.STORAGE_BASE_DIR))
|
||||
path = Path(os.path.realpath(root.joinpath(*parts[1:])))
|
||||
if root not in path.parents or not path.is_dir():
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _stale_frame(document: Any) -> bool:
|
||||
"""옛 표 틀 — 계산 열에 바인딩(설계값 딱지)이 붙은 판."""
|
||||
return isinstance(document, dict) and any(
|
||||
isinstance(column, dict)
|
||||
and column.get("바인딩")
|
||||
and (column.get("식") or column["바인딩"].get("값") == "식")
|
||||
for column in document.get("열") or []
|
||||
)
|
||||
|
||||
|
||||
def refresh_tables(root: Path, *, dry_run: bool = False) -> list[str]:
|
||||
"""옛 표 틀을 시스템 새 판으로 — 작업본 · `_initial/` 둘 다 · 줄(손 값) · 변수 · 보기는 지킴.
|
||||
|
||||
손댄 것이 없으면 시스템 파일을 그대로 복사(판이 시스템과 같음).
|
||||
"""
|
||||
done: list[str] = []
|
||||
for row in layers.list_templates(layers.system_dir()):
|
||||
if row["종류"] != "table":
|
||||
continue
|
||||
system = layers.read_template(layers.system_dir(), "table", row["이름"])
|
||||
for folder in (layers.project_dir(root), layers.initial_dir(root)):
|
||||
got = layers.read_template(folder, "table", row["이름"])
|
||||
if not system or not got or not _stale_frame(got["문서"]):
|
||||
continue
|
||||
done.append(f"{folder.name}/table/{row['이름']}")
|
||||
if dry_run:
|
||||
continue
|
||||
old = fill.strip_design(got["문서"])
|
||||
doc = copy.deepcopy(system["문서"])
|
||||
doc["변수"] = {**(doc.get("변수") or {}), **(old.get("변수") or {})}
|
||||
doc.update({key: old[key] for key in ("줄", "보기") if key in old})
|
||||
if doc == system["문서"]:
|
||||
layers.copy_templates(
|
||||
layers.system_dir(),
|
||||
folder,
|
||||
source_layer="system",
|
||||
kind="table",
|
||||
name=row["이름"],
|
||||
)
|
||||
else:
|
||||
layers.write_template(folder, "table", row["이름"], doc)
|
||||
layers.note_source(folder, "table", row["이름"], "system", system["판"])
|
||||
return done
|
||||
|
||||
|
||||
def migrate(
|
||||
projects: list[dict[str, Any]], *, dry_run: bool = False, refresh: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 표갱신, 상태}]`."""
|
||||
system = {f"{row['종류']}/{row['이름']}" for row in layers.list_templates(layers.system_dir())}
|
||||
report = []
|
||||
for project in projects:
|
||||
root = project_root(project.get("storage_path"))
|
||||
entry = {**project, "작업본": [], "초기": [], "표갱신": [], "상태": ""}
|
||||
if root is not None and refresh:
|
||||
entry["표갱신"] = refresh_tables(root, dry_run=dry_run)
|
||||
if root is None:
|
||||
entry["상태"] = "폴더 없음 — 건너뜀"
|
||||
elif dry_run:
|
||||
have = {
|
||||
f"{row['종류']}/{row['이름']}"
|
||||
for row in layers.list_templates(layers.project_dir(root))
|
||||
}
|
||||
entry["작업본"] = sorted(system - have)
|
||||
entry["상태"] = "넣을 것(시험)"
|
||||
else:
|
||||
done = layers.seed_project(root, only_missing=True)
|
||||
entry.update(작업본=done["작업본"], 초기=done["초기"])
|
||||
entry["상태"] = "넣음" if done["작업본"] or done["초기"] else "이미 있음"
|
||||
report.append(entry)
|
||||
return report
|
||||
|
||||
|
||||
def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str:
|
||||
names = " · ".join(f"{row['종류']}/{row['이름']}" for row in system) or "없음"
|
||||
lines = [
|
||||
"# 기존 프로젝트 양식 넣기",
|
||||
"",
|
||||
f"- 돌린 때: {datetime.now().isoformat(timespec='seconds')}",
|
||||
f"- 시스템 양식: {names}",
|
||||
"- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로",
|
||||
f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개",
|
||||
"",
|
||||
"| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 표 틀 새 판 | 상태 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for row in report:
|
||||
added = " · ".join(row["작업본"]) or "—"
|
||||
lines.append(
|
||||
f"| {row['company_id']} | {row.get('owner') or row['user_id']} | {row['name']} "
|
||||
f"| `{row['id']}` | {added} | {' · '.join(row['표갱신']) or '—'} | {row['상태']} |"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--refresh-tables", action="store_true")
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
await init_db_pool()
|
||||
try:
|
||||
projects = await _projects()
|
||||
finally:
|
||||
await close_db_pool()
|
||||
report = migrate(projects, dry_run=args.dry_run, refresh=args.refresh_tables)
|
||||
text = render(report, layers.list_templates(layers.system_dir()))
|
||||
if args.report:
|
||||
args.report.write_text(text, encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
"""표 문서(마스터 템플릿 표 양식) 서버 재계산 — 화면과 같은 TS 를 Node 로 돌리는 껍데기.
|
||||
|
||||
CLAUDE.md 5장 ② — 계산은 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌.
|
||||
여기는 번들을 부르기만 함(`npm run build:formula` → `config/formula_node/`).
|
||||
[저장] 때 브라우저 값을 받아 적지 않고 이 결과를 정본으로 씀.
|
||||
|
||||
결과 = `{계산: {줄: {열: 값}}, 합계: {합계줄: {열: 값}}, 오류: [{줄, 열, 까닭}]}` · 값은 십진 글.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json
|
||||
|
||||
BUNDLE = ROOT / "config" / "formula_node" / "common_util_sheet_recalc_node.js"
|
||||
NPM_SCRIPT = "build:formula"
|
||||
SOURCE_DIR = ROOT / "ui_template" / "sheet"
|
||||
ENTRY = ROOT / "common_util" / "common_util_sheet_recalc_node.ts"
|
||||
|
||||
|
||||
def _stale() -> bool:
|
||||
"""번들이 없거나 표 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄."""
|
||||
if not BUNDLE.is_file():
|
||||
return True
|
||||
built_at = BUNDLE.stat().st_mtime
|
||||
return any(p.stat().st_mtime > built_at for p in [ENTRY, *SOURCE_DIR.glob("*.ts")])
|
||||
|
||||
|
||||
def recalc_sheets(docs: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""표 문서 여럿을 한 번에 — 입력 차례 그대로 결과. 번들 빌드·실행 실패는 None."""
|
||||
if _stale() and not build_bundle(NPM_SCRIPT):
|
||||
return None
|
||||
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"docs": docs})
|
||||
return None if out is None else out["results"]
|
||||
|
||||
|
||||
def recalc_sheet(doc: dict[str, Any]) -> dict[str, Any] | None:
|
||||
results = recalc_sheets([doc])
|
||||
return None if results is None else results[0]
|
||||
@@ -0,0 +1,27 @@
|
||||
/* =============================================================================
|
||||
* common_util_sheet_recalc_node.ts
|
||||
* 표 문서 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본으로 다시 풂.
|
||||
*
|
||||
* 풀이는 화면이 쓰는 `ui_template_sheet_recalc.ts` 그대로 — 여기에는 계산이 없음.
|
||||
* 파이썬 껍데기 = `common_util_sheet_recalc.py` · 번들 = `npm run build:formula`.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { docs: SheetDoc[] }
|
||||
* 출력 { results: SheetResult[] } — 입력 차례 그대로
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `ui_template/sheet/` 밖에 둠 — M02 화면이 그 폴더를 통째로 훑어 불러옴(node:fs 가 브라우저로 새지 않게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { recalcSheet } from "@ui/sheet/ui_template_sheet_recalc";
|
||||
import type { SheetDoc } from "@ui/sheet/ui_template_sheet_types";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { docs?: SheetDoc[] };
|
||||
const results = (input.docs ?? []).map((doc) => recalcSheet(doc));
|
||||
writeFileSync(outputPath, JSON.stringify({ results }));
|
||||
@@ -118,6 +118,8 @@ export const ROUTES = {
|
||||
B11_LOADING: "b11-loading",
|
||||
// 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기.
|
||||
M01_MASTER_DATA: "m01-master-data",
|
||||
// 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인.
|
||||
M02_MASTER_TEMPLATE: "m02-master-template",
|
||||
} as const;
|
||||
|
||||
export type RouteKey = keyof typeof ROUTES;
|
||||
@@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.B11_LOADING,
|
||||
ROUTES.M01_MASTER_DATA,
|
||||
ROUTES.M02_MASTER_TEMPLATE,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 26},
|
||||
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 38},
|
||||
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}},
|
||||
"키": {
|
||||
"CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"},
|
||||
@@ -36615,6 +36615,18 @@
|
||||
"UA000022": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000023": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000024": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000025": {"원문번호": "", "파일": "일위대가조합.json"}
|
||||
"UA000025": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000026": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000027": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000028": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000029": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000030": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000031": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000032": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000033": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000034": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000035": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000036": {"원문번호": "", "파일": "일위대가조합.json"},
|
||||
"UA000037": {"원문번호": "", "파일": "일위대가조합.json"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,151 @@
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 거창 제7호표 · 거창 제8호표"
|
||||
},
|
||||
{
|
||||
"키": "UA000026",
|
||||
"이름": "횡단개거(m)",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [{ "로직": "GC000088", "메모": "구조물 터파기(토사) 굴삭기 0.2m3" }, { "로직": "GF000113", "메모": "구조물 되메우기 굴삭기 0.2m3 (실무 수는 옛 판 값)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 봉화 B00007"
|
||||
},
|
||||
{
|
||||
"키": "UA000027",
|
||||
"이름": "PE관 매설",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [{ "로직": "GC000088", "메모": "구조물 터파기(토사) 굴삭기 0.2m3" }, { "로직": "GF000113", "메모": "구조물 되메우기 굴삭기 0.2m3 (실무 수는 옛 판 값)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "PE관 부설 및 접합 D50 — 후보 「부단수 할정자관 부설 및 접합」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n실무 호표 — 봉화 B00039"
|
||||
},
|
||||
{
|
||||
"키": "UA000028",
|
||||
"이름": "선떼",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [{ "로직": "GC000143", "메모": "평떼붙임 (실무 수는 옛 판 값)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "단끊기 — 우리 로직에 없음\n단끊기 절취없음 — 우리 로직에 없음\n떼 하차비 — 우리 로직에 없음\n실무 호표 — 봉화 B00052 · 울진소광 B02130"
|
||||
},
|
||||
{
|
||||
"키": "UA000029",
|
||||
"이름": "동물이동통로",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [{ "로직": "GF000101", "메모": "구조물터파기 토사(B/H 90%+인력10%)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "잔토처리 토사(B/H 100%) — 우리 로직에 없음\n실무 호표 — 영월 B01963"
|
||||
},
|
||||
{
|
||||
"키": "UA000030",
|
||||
"이름": "측구보호용잡석채움",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [{ "로직": "GF000218", "메모": "잡석운반및다짐 (실무 수는 옛 판 값)" }, { "로직": "GF000124", "메모": "면고르기" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 영월 B01984"
|
||||
},
|
||||
{
|
||||
"키": "UA000031",
|
||||
"이름": "큰돌메쌓기",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "㎡",
|
||||
"담은로직": [{ "로직": "GF000218", "메모": "뒷채움돌채집운반부설 4.5톤 덤프트럭(L=500m) (실무 수는 옛 판 값)" }, { "로직": "GF000226", "메모": "큰돌메쌓기 뒷길이 0.6~0.8m" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n큰돌채집운반(뒷길이 0.6~0.8m) 장비(80%) + 인력(20%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n녹생토(종자배합) 암절개지 및 특수지반 녹화용 (700kg/㎥) — 우리 로직에 없음\n실무 호표 — 영월 B02133"
|
||||
},
|
||||
{
|
||||
"키": "UA000032",
|
||||
"이름": "큰돌흙막이",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "㎡",
|
||||
"담은로직": [{ "로직": "GF000218", "메모": "뒷채움돌채집운반부설 4.5톤 덤프트럭(L=500m) (실무 수는 옛 판 값)" }, { "로직": "GF000226", "메모": "큰돌메쌓기 뒷길이 0.6~0.8m" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "큰돌채집운반(뒷길이 0.6~0.8m) 장비(80%) + 인력(20%) — 후보 「모래·자갈·약돌 채집(인력)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n고임돌채집운반 4.5톤덤프(L=500) — 후보 「큰돌 채집(기계)」 와 수가 다름(옛 판 값) · 이름이 달라 뺌\n녹생토(종자배합) 암절개지 및 특수지반 녹화용 (700kg/㎥) — 우리 로직에 없음\n실무 호표 — 영월 B02156"
|
||||
},
|
||||
{
|
||||
"키": "UA000033",
|
||||
"이름": "수로형토사개거",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "개소",
|
||||
"담은로직": [{ "로직": "GF000101", "메모": "구조물터파기(토사) 굴삭기0.7m3 (실무 수는 옛 판 값)" }, { "로직": "GC000105", "메모": "다지기 굴삭기0.7m3 (실무 수는 옛 판 값)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 울진대흥 B01221"
|
||||
},
|
||||
{
|
||||
"키": "UA000034",
|
||||
"이름": "물넘이집수부",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "개소",
|
||||
"담은로직": [
|
||||
{ "로직": "GC000206", "메모": "철근가공 및 조립 Type-Ⅱ-2,현장가공조립 (실무 수는 옛 판 값)" },
|
||||
{ "로직": "GC000207", "메모": "철근가공 및 조립 Type-Ⅱ-2,현장가공조립 (실무 수는 옛 판 값)" },
|
||||
{ "로직": "GF000156", "메모": "콘크리트믹서사용 0.45 m3 (실무 수는 옛 판 값)" },
|
||||
{ "로직": "GC000214", "메모": "유로폼 설치 및 해체 보통" }
|
||||
],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 울진대흥 B01234"
|
||||
},
|
||||
{
|
||||
"키": "UA000035",
|
||||
"이름": "연결철근",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "kg",
|
||||
"담은로직": [{ "로직": "GC000206", "메모": "철근(현장)가공 Type-Ⅰ-----아님 (실무 수는 옛 판 값)" }, { "로직": "GC000207", "메모": "철근(현장)조립 Type-Ⅰ-----아님" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 울진소광 B00287"
|
||||
},
|
||||
{
|
||||
"키": "UA000036",
|
||||
"이름": "파형강관 부설·매설",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "m",
|
||||
"담은로직": [
|
||||
{ "로직": "GF000176", "메모": "파형강관부설 D=800mm (실무 수는 옛 판 값)" },
|
||||
{ "로직": "GC000088", "메모": "구조물터파기(토사) 굴착기0.6m3" },
|
||||
{ "로직": "GF000113", "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)" }
|
||||
],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "구조물잔토처리 굴착기0.6m3 — 우리 로직에 없음\n실무 호표 — 울진소광 B02119"
|
||||
},
|
||||
{
|
||||
"키": "UA000037",
|
||||
"이름": "급수및노폭좁은안내판",
|
||||
"구분": "실무 일위대가",
|
||||
"상세구분": null,
|
||||
"단위": "개소",
|
||||
"담은로직": [
|
||||
{ "로직": "GC000308", "메모": "안내표지판 설치 단주식 (실무 수는 옛 판 값)" },
|
||||
{ "로직": "GF000155", "메모": "레미콘타설(인력운반타설) 소형구조물" },
|
||||
{ "로직": "GC000211", "메모": "합판거푸집 6회, 간단 (실무 수는 옛 판 값)" }
|
||||
],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "실무 호표 — 거창 제13호표"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 6,
|
||||
"source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)",
|
||||
"drawing_area": [42, 47, 812, 567],
|
||||
"entities": [
|
||||
{
|
||||
"id": "4afa84ae-9c15-50ec-8a76-db87d04d6311",
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"양식": "구조물집계표",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
|
||||
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)",
|
||||
"층": ["종류", "공법", "규격"],
|
||||
"변수": {
|
||||
"파형강관_1본_m": 8,
|
||||
"흄관_1본_m": "",
|
||||
"VR관_1본_m": "",
|
||||
"수축줄눈_간격_m": 6,
|
||||
"측점간격_m": 20
|
||||
},
|
||||
"쪽줄": 50,
|
||||
"열": [
|
||||
{ "id": "no", "머리": ["NO", null, null], "단위": null, "꼴": "글", "설명": "줄 차례(전구간 줄 빼고 1부터)" },
|
||||
{ "id": "sta", "머리": ["측점", null, null], "단위": null, "꼴": "글", "설명": "점 NO.x+y · 구간 NO.a~NO.b · 맨 위 전구간" },
|
||||
|
||||
{
|
||||
"id": "rv", "머리": ["돌기슭막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 기슭막이 · 관 유입·유출 기슭막이 길이 · 형태 → 높이마다 열",
|
||||
"일위대가": "UA000004",
|
||||
"바인딩": {
|
||||
"종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv",
|
||||
"머리틀": ["돌기슭막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형태 미입력", "높이 미입력"],
|
||||
"이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" },
|
||||
"더함": [
|
||||
{ "종류": "pipe", "펼침": ["inlet_revet_form", "inlet_revet_height_m"], "값": "inlet_revet_length_m", "조건": { "inlet_type": "기슭막이" } },
|
||||
{ "종류": "pipe", "펼침": ["outlet_revet_form", "outlet_revet_height_m"], "값": "outlet_revet_length_m", "조건": { "outlet_type": "기슭막이" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pv_b", "머리": ["콘크리트포장", "T=(두께마다)", "B"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 포장 폭",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "B"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 포장 길이",
|
||||
"일위대가": "UA000007",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "L"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 확폭 면적",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "확폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"식": "IF([pv_l]>0,[pv_b]*[pv_l]+[pv_w],\"\")",
|
||||
"끝수": { "자리": 2, "방법": "반올림" },
|
||||
"설명": "계산 — B × L + 확폭",
|
||||
"일위대가": "UA000006",
|
||||
"펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "A"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")",
|
||||
"끝수": { "자리": 2, "방법": "반올림" },
|
||||
"설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)",
|
||||
"펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "수축줄눈"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pp_len", "머리": ["관공", "(관종·관경마다)", "관매설"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔",
|
||||
"일위대가": "UA000036",
|
||||
"일위대가후보": ["UA000036", "UA000027"],
|
||||
"바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} {1}", "관매설"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "pp_cp", "머리": ["관공", "(관종·관경마다)", "커플링밴드"], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"식": "IF([pp_len]>0,ROUNDUP([pp_len]/[$파형강관_1본_m],0)-1,\"\")",
|
||||
"설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)",
|
||||
"펼침틀": {
|
||||
"묶음": "pp", "머리틀": ["관공", "{0} {1}", "커플링밴드"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"],
|
||||
"식틀": "IF([pp_len]>0,ROUNDUP([pp_len]/[${0}_1본_m],0)-1,\"\")"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pg_in", "머리": ["관보호공", "(형식마다)", "유입"], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 관 날개벽 형식 · 유입구가 집수정이면 집수정 형식 · 형식마다 열(유출구는 기슭막이 — 돌기슭막이 열)",
|
||||
"일위대가": "UA000008",
|
||||
"일위대가후보": ["UA000008", "UA000025", "UA000029"],
|
||||
"바인딩": {
|
||||
"종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"], "빈값": ["형식 미입력"],
|
||||
"이름": { "A-TYPE": "날개벽 A형", "C-TYPE": "날개벽 C형", "A-TYPE+집수정": "날개벽 A형+집수정" },
|
||||
"더함": [ { "종류": "pipe", "펼침": ["inlet_basin_form"], "값": "개소", "조건": { "inlet_type": "집수정" } } ]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "bx_len", "머리": ["BOX암거", "(폭×높이마다)", "연장"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — BOX 연장(계류 방향)",
|
||||
"일위대가": "UA000014",
|
||||
"바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"], "빈값": ["폭 미입력", "높이 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "bx_wing", "머리": ["BOX암거", "(폭×높이마다)", "날개벽"], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 날개벽 유입·유출 있음 수",
|
||||
"일위대가": "UA000025",
|
||||
"바인딩": {
|
||||
"종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"], "빈값": ["폭 미입력", "높이 미입력"],
|
||||
"조건": { "wing_in": "있음" },
|
||||
"더함": [ { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "조건": { "wing_out": "있음" } } ]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "fb", "머리": ["세월교", "(관경 × 련수마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 세월교 개소",
|
||||
"바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "{0}×{1}", null], "값꼴": ["Φ{}", "{}련"], "빈값": ["관경 미입력", "련수 미입력"] }
|
||||
},
|
||||
|
||||
{
|
||||
"id": "fp_w", "머리": ["물넘이포장", "T=(두께마다)", "폭"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 월류 폭",
|
||||
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 포장 길이(노폭 방향)",
|
||||
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "길이"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
|
||||
},
|
||||
|
||||
{
|
||||
"id": "od_len", "머리": ["횡단개거", "(규격마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 개거 연장",
|
||||
"일위대가": "UA000026",
|
||||
"바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"], "빈값": ["규격 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "od_cnt", "머리": ["횡단개거", "(규격마다)", "개소"], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 개거 · 노출형 횡단수로 개소",
|
||||
"일위대가": "UA000003",
|
||||
"바인딩": {
|
||||
"종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"], "빈값": ["규격 미입력"],
|
||||
"더함": [ { "종류": "cross_drain_exposed", "펼침": ["=노출형"], "값": "개소" } ]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "rw", "머리": ["옹벽", "(형식마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 옹벽 길이",
|
||||
"바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 돌쌓기 길이",
|
||||
"바인딩": {
|
||||
"종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["", "높이 미입력"],
|
||||
"더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만",
|
||||
"일위대가": "UA000032",
|
||||
"바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 큰돌쌓기 길이",
|
||||
"바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "길이"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"설명": "계산 — L × H (H = 펼친 높이)",
|
||||
"일위대가": "UA000031",
|
||||
"펼침틀": {
|
||||
"묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "면적"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"],
|
||||
"식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 골막이 개소",
|
||||
"바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null], "빈값": ["형식 미입력"] }
|
||||
},
|
||||
{
|
||||
"id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수",
|
||||
"설명": "설계값 — 소단 길이",
|
||||
"바인딩": { "종류": "berm", "값": "길이" }
|
||||
},
|
||||
{ "id": "rf", "머리": ["대피소", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 대피소", "바인딩": { "종류": "refuge", "값": "개소" } },
|
||||
{ "id": "wy", "머리": ["정차·작업장", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 정차·작업장", "바인딩": { "종류": "work_yard", "값": "개소" } },
|
||||
{ "id": "ta", "머리": ["차돌림곳", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차돌림곳", "바인딩": { "종류": "turnaround", "값": "개소" } },
|
||||
{ "id": "gr_rail", "머리": ["가드레일", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 가드레일 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "가드레일" } } },
|
||||
{ "id": "gr_curb", "머리": ["경계석", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 경계석 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "경계석" } } },
|
||||
{ "id": "gr_sign", "머리": ["위험표지", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 위험표지", "일위대가": "UA000017", "바인딩": { "종류": "guardrail", "값": "개소", "조건": { "kind": "위험표지" } } },
|
||||
{ "id": "mr", "머리": ["반사경", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 반사경", "일위대가": "UA000018", "바인딩": { "종류": "mirror", "값": "개소" } },
|
||||
{ "id": "bg", "머리": ["차단기", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차단기", "일위대가": "UA000010", "바인딩": { "종류": "barrier_gate", "값": "개소" } },
|
||||
{ "id": "ps", "머리": ["국가지점번호판", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 번호판 · 전구간 줄은 손 입력", "일위대가": "UA000016", "바인딩": { "종류": "position_sign", "값": "개소" } },
|
||||
{ "id": "cs", "머리": ["준공표지석", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 준공표지판", "일위대가": "UA000002", "바인딩": { "종류": "completion_sign", "값": "개소" } },
|
||||
{
|
||||
"id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 기타 구조물 개소",
|
||||
"바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null], "빈값": ["이름 미입력"] }
|
||||
},
|
||||
|
||||
{ "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" },
|
||||
{ "id": "h_rip_c", "머리": ["돌붙임", "찰붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
|
||||
{ "id": "h_rip_m", "머리": ["돌붙임", "메붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
|
||||
{ "id": "h_stone_ch", "머리": ["돌수로", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000011" },
|
||||
{ "id": "h_stone_bed", "머리": ["돌조공", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000023" },
|
||||
{ "id": "h_pv_rail", "머리": ["포장 난간", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000012" },
|
||||
{ "id": "h_pg_rail", "머리": ["관보호공 안전난간", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000013" },
|
||||
{ "id": "h_soil_ditch", "머리": ["수로형 토사개거", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000033" },
|
||||
{ "id": "h_ford_basin", "머리": ["물넘이 집수부", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000034" },
|
||||
{ "id": "h_rockfall", "머리": ["낙석방지책", null, null], "단위": "경간", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
{ "id": "h_break", "머리": ["콘크리트·포장 깨기", null, null], "단위": "㎥", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
{ "id": "h_waste", "머리": ["폐기물처리", null, null], "단위": "ton", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
|
||||
{ "id": "memo", "머리": ["비고", null, null], "단위": null, "꼴": "글", "손": true, "설명": "손 입력" }
|
||||
],
|
||||
"줄": [
|
||||
{ "id": "all", "값": { "sta": "전구간" }, "고정": "전구간" }
|
||||
],
|
||||
"합계줄": [
|
||||
{ "id": "sum", "이름": "합계", "식": "SUM" }
|
||||
],
|
||||
"보기": { "틀고정": { "열": 2 }, "열너비": { "no": 40, "sta": 150, "memo": 120 } }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, FRAME_LAYER_ID
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
|
||||
TEMPLATE = Path("resources/template_2dDrawing/00_template_cover.json")
|
||||
TEMPLATE = Path("resources/master_template/drawing/00_template_cover.json")
|
||||
|
||||
# A1 도각 템플릿과 같은 재단 표식 원점 — 두 도면이 같은 종이 좌표계를 써야 한다.
|
||||
PAPER = (-5.05, -5.04, 834.95, 588.96)
|
||||
@@ -62,9 +62,7 @@ def test_도면은_잠금_도각_레이어로_나온다():
|
||||
assert {e["layerId"] for e in drawing["entities"]} == {FRAME_LAYER_ID}
|
||||
layers = {layer["id"]: layer for layer in drawing["layers"]}
|
||||
assert layers[FRAME_LAYER_ID]["isLocked"] is True
|
||||
assert any(not layer["isLocked"] for layer in drawing["layers"]), (
|
||||
"덧그릴 비잠금 도면층이 없다"
|
||||
)
|
||||
assert any(not layer["isLocked"] for layer in drawing["layers"]), "덧그릴 비잠금 도면층이 없다"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""M02 도면 양식 — 작도 영역 칸 · B07 도각 층(프로젝트 작업본 → 시스템) (PLAN 10-3)."""
|
||||
|
||||
import json
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
A1 = engine.A1_TEMPLATE
|
||||
|
||||
|
||||
def _project_template(project_root, document):
|
||||
path = engine.project_template_path(project_root)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _entities(project_root):
|
||||
engine.use_project_templates(project_root)
|
||||
try:
|
||||
return engine.template_entities()
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_system_a1_reads_drawing_area():
|
||||
engine.use_project_templates(None)
|
||||
assert engine.drawing_area() == (42.0, 47.0, 812.0, 567.0)
|
||||
assert len(engine.template_entities()) == 51
|
||||
|
||||
|
||||
def test_missing_area_falls_back_to_a1(tmp_path):
|
||||
_project_template(tmp_path, {"format": 6, "entities": [], "layers": []})
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == engine._A1_INNER
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_template_area_moves_content(tmp_path):
|
||||
line = {
|
||||
"id": "a",
|
||||
"type": "Line",
|
||||
"shapeData": {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 10, "y": 0}},
|
||||
}
|
||||
_project_template(
|
||||
tmp_path,
|
||||
{"format": 6, "drawing_area": [0, 0, 400, 200], "entities": [line], "layers": []},
|
||||
)
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == (0.0, 0.0, 400.0, 200.0)
|
||||
assert engine.usable_area() == (400 * 0.96, 200 * 0.96)
|
||||
# 콘텐츠 중심(500, 500) 이 작도 영역 중심(200, 100) 에 온다 — 이동량 (300, 400).
|
||||
placed = engine.frame_entities("t", (490.0, 490.0, 510.0, 510.0), fit=False)
|
||||
assert placed[0]["shapeData"]["startPoint"] == {"x": 300.0, "y": 400.0}
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_bad_area_falls_back(tmp_path):
|
||||
_project_template(tmp_path, {"drawing_area": [10, 10, 5, 5], "entities": []})
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == engine._A1_INNER
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_old_project_reads_system(tmp_path):
|
||||
# 작업본이 없는 옛 프로젝트 — 시스템 양식으로 떨어진다.
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
|
||||
def test_save_reset_on_seeded_project(tmp_path):
|
||||
layers.seed_project(tmp_path)
|
||||
assert engine.project_template_path(tmp_path).is_file()
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
# [완료] — 작업본에만 쓴다 · 작도 영역은 이어 받는다 · `_initial/` · 시스템은 그대로.
|
||||
path = engine.save_project_template(tmp_path, [])
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert saved["drawing_area"] == [42, 47, 812, 567]
|
||||
assert _entities(tmp_path) == []
|
||||
assert engine.is_project_template_customized(tmp_path) is True
|
||||
initial = layers.template_path(layers.initial_dir(tmp_path), "drawing", A1)
|
||||
assert len(json.loads(initial.read_text(encoding="utf-8"))["entities"]) == 51
|
||||
assert (
|
||||
len(json.loads(engine.system_template_path().read_text(encoding="utf-8"))["entities"]) == 51
|
||||
)
|
||||
|
||||
# [기본 도각으로] — `_initial/` 을 작업본에 덮어쓴다.
|
||||
assert engine.reset_project_template(tmp_path) is True
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
|
||||
def test_reset_on_old_project_drops_to_system(tmp_path):
|
||||
engine.save_project_template(tmp_path, [])
|
||||
assert _entities(tmp_path) == []
|
||||
assert engine.reset_project_template(tmp_path) is True
|
||||
assert not engine.project_template_path(tmp_path).exists()
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.reset_project_template(tmp_path) is False
|
||||
@@ -0,0 +1,67 @@
|
||||
"""M02 도면 양식 서버 길 — 자리표 키 목록 · 도각 파일 불러오기 (PLAN 10-3)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import DRAWING_FIELDS, router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_fields_cover_system_placeholders():
|
||||
# 시스템 도면 양식에 박힌 자리표는 전부 키 목록에 있다 — 없으면 화면이 모르는 칸이 생긴다.
|
||||
keys = {key for key, _, _ in DRAWING_FIELDS}
|
||||
used = set()
|
||||
for path in Path("resources/master_template/drawing").glob("00_*.json"):
|
||||
text = json.dumps(json.loads(path.read_text(encoding="utf-8")), ensure_ascii=False)
|
||||
used |= {match.strip() for match in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", text)}
|
||||
assert used and used <= keys, used - keys
|
||||
|
||||
|
||||
def test_fields_without_project():
|
||||
response = client.get("/api/m02/drawing-fields")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [item["key"] for item in body["fields"]] == [key for key, _, _ in DRAWING_FIELDS]
|
||||
assert body["values"] == {}
|
||||
|
||||
|
||||
def test_import_rejects_other_files():
|
||||
response = client.post(
|
||||
"/api/m02/drawing-import", files={"file": ("a.txt", b"hello", "text/plain")}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "DXF" in response.json()["message"]
|
||||
|
||||
|
||||
def test_import_dxf():
|
||||
import ezdxf
|
||||
|
||||
document = ezdxf.new()
|
||||
document.modelspace().add_line((0, 0), (100, 50))
|
||||
stream = io.StringIO()
|
||||
document.write(stream)
|
||||
response = client.post(
|
||||
"/api/m02/drawing-import",
|
||||
files={"file": ("frame.dxf", stream.getvalue().encode("utf-8"), "application/dxf")},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["entity_count"] == 1
|
||||
assert body["drawing"]["entities"][0]["type"] == "Line"
|
||||
|
||||
|
||||
def test_import_garbage_is_400_not_500():
|
||||
for data in (b"garbage\x00\xff", b"", b"0\nSECTION\n2\nENTITIES\n0\nLINE\n10\nabc\n"):
|
||||
response = client.post(
|
||||
"/api/m02/drawing-import", files={"file": ("bad.dxf", data, "application/dxf")}
|
||||
)
|
||||
assert response.status_code == 400, (data, response.text)
|
||||
assert response.json()["message"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""M02 시스템 층 저장소·길 — 새로·저장·다시 열기·지우기·409 (사본 폴더로)."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
for kind in store.KINDS:
|
||||
(tmp_path / kind).mkdir()
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_new_save_reopen_delete_and_stale(client):
|
||||
assert client.get("/api/m02/templates").json() == []
|
||||
|
||||
made = client.put("/api/m02/templates/table/집계표", json={"판": "", "문서": {"열": [1]}})
|
||||
assert made.status_code == 200
|
||||
v1 = made.json()["판"]
|
||||
assert (
|
||||
client.put(
|
||||
"/api/m02/templates/table/집계표", json={"판": "", "문서": {"열": []}}
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
|
||||
got = client.get("/api/m02/templates/table/집계표").json()
|
||||
assert got["판"] == v1 and got["문서"] == {"열": [1]}
|
||||
|
||||
saved = client.put("/api/m02/templates/table/집계표", json={"판": v1, "문서": {"열": [1, 2]}})
|
||||
assert saved.status_code == 200 and saved.json()["판"] != v1
|
||||
stale = client.put("/api/m02/templates/table/집계표", json={"판": v1, "문서": {"열": []}})
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["stale"] == ["집계표"]
|
||||
|
||||
rows = client.get("/api/m02/templates").json()
|
||||
assert [(r["종류"], r["이름"]) for r in rows] == [("table", "집계표")]
|
||||
|
||||
assert client.delete("/api/m02/templates/table/집계표").status_code == 200
|
||||
assert client.get("/api/m02/templates/table/집계표").status_code == 404
|
||||
|
||||
|
||||
def test_bad_names_and_kinds(client):
|
||||
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"):
|
||||
got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}})
|
||||
assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"]
|
||||
assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404
|
||||
|
||||
|
||||
def test_empty_doc_rejected_and_file_untouched(client):
|
||||
good = {"format": 6, "entities": [{"type": "line"}]}
|
||||
made = client.put("/api/m02/templates/drawing/도", json={"판": "", "문서": good})
|
||||
v = made.json()["판"]
|
||||
for bad in ({}, {"format": 6}, {"entities": "x"}, []):
|
||||
r = client.put("/api/m02/templates/drawing/도", json={"판": v, "문서": bad})
|
||||
assert r.status_code == 400
|
||||
assert client.put("/api/m02/templates/table/표", json={"판": "", "문서": {}}).status_code == 400
|
||||
assert client.get("/api/m02/templates/drawing/도").json()["문서"] == good
|
||||
assert client.get("/api/m02/templates/table/표").status_code == 404
|
||||
@@ -0,0 +1,212 @@
|
||||
"""M02 구조물 집계표 채우기 — 펼침 열 · 측점 줄 · 관 줄 나눔 · 손 값 지킴 (임시 프로젝트)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
|
||||
MASTER = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
|
||||
|
||||
def _master() -> dict[str, Any]:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "proj"
|
||||
route = root / "B05_Profile/route"
|
||||
route.mkdir(parents=True)
|
||||
structures = [
|
||||
{
|
||||
"type_id": "masonry_dry",
|
||||
"chainage_m": 85,
|
||||
"start_m": 80,
|
||||
"end_m": 90,
|
||||
"options": {"height_m": 2.0, "length_m": 10},
|
||||
},
|
||||
{
|
||||
"type_id": "masonry_wet",
|
||||
"chainage_m": 80,
|
||||
"start_m": 80,
|
||||
"end_m": 90,
|
||||
"options": {"height_m": 2.5, "length_m": 10},
|
||||
},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 205,
|
||||
"start_m": 200,
|
||||
"end_m": 230,
|
||||
"options": {"length_m": 30, "width_m": 3, "thickness_cm": 20, "joint_spacing_m": 5},
|
||||
},
|
||||
{"type_id": "position_sign", "chainage_m": 300},
|
||||
{
|
||||
"type_id": "boulder_masonry",
|
||||
"chainage_m": 325,
|
||||
"start_m": 320,
|
||||
"end_m": 330,
|
||||
"options": {"height_m": 2.5, "length_m": 10, "bond": "메쌓기", "stone_cm": "40~60"},
|
||||
},
|
||||
]
|
||||
for index, item in enumerate(structures):
|
||||
item.setdefault("structure_id", f"st{index}")
|
||||
item.setdefault("placement", "interval" if "start_m" in item else "point")
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
edits = root / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
points = [
|
||||
{"chainage_m": 85.05, "source": "user", "options": {"pipe_diameter_mm": "1000"}},
|
||||
{"chainage_m": 85.05, "source": "user", "options": {"pipe_kind": "흄관"}},
|
||||
{"chainage_m": 173.09, "source": "user", "facility": "ford_bridge", "options": {}},
|
||||
]
|
||||
(edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _filled(project: Path, document: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return fill.fill_table(project, document or _master(), {85.0: 14.0})
|
||||
|
||||
|
||||
def _col(doc: dict[str, Any], col_id: str) -> dict[str, Any]:
|
||||
return next(column for column in doc["열"] if column["id"] == col_id)
|
||||
|
||||
|
||||
def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
ids = [column["id"] for column in doc["열"]]
|
||||
assert [i for i in ids if i.startswith("ms|")] == ["ms|찰쌓기|2.5", "ms|메쌓기|2.0"]
|
||||
assert _col(doc, "ms|메쌓기|2.0")["머리"] == ["돌쌓기", "메쌓기", "H=2.0"]
|
||||
assert [i for i in ids if i.startswith("pp_len|")] == [
|
||||
"pp_len|흄관|1000",
|
||||
"pp_len|파형강관|1000",
|
||||
]
|
||||
assert not any(i.startswith("rw|") for i in ids) # 옹벽 없음 → 열 없음
|
||||
assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음
|
||||
# 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값
|
||||
assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids
|
||||
assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "높이 미입력"]
|
||||
assert _col(doc, "fb|1000|-")["머리"] == ["세월교", "Φ1000×련수 미입력", None]
|
||||
assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음
|
||||
|
||||
|
||||
def test_묶음_열은_값마다_한데(project: Path) -> None:
|
||||
ids = [column["id"] for column in _filled(project)["열"]]
|
||||
assert [i for i in ids if i.startswith("pp_")] == [
|
||||
"pp_len|흄관|1000",
|
||||
"pp_cp|흄관|1000",
|
||||
"pp_len|파형강관|1000",
|
||||
"pp_cp|파형강관|1000",
|
||||
]
|
||||
assert [i for i in ids if i.startswith("pv_")] == [
|
||||
f"pv_{k}|20" for k in ("b", "l", "w", "a", "jt")
|
||||
]
|
||||
|
||||
|
||||
def test_계산_열은_설계값_딱지_없음(project: Path) -> None:
|
||||
master = _master()
|
||||
assert not [c["id"] for c in master["열"] if c.get("식") and c.get("바인딩")]
|
||||
doc = _filled(project)
|
||||
for col_id in ("pv_a|20", "pv_jt|20", "pp_cp|파형강관|1000", "bm_area|메쌓기|40~60|2.5"):
|
||||
column = _col(doc, col_id)
|
||||
assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0]
|
||||
assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"]
|
||||
# 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림)
|
||||
again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0})
|
||||
assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]]
|
||||
|
||||
|
||||
def test_계산_열_식은_같은_묶음_열로_바뀌고_빈_변수면_안_셈(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
assert _col(doc, "pv_a|20")["식"] == 'IF([pv_l|20]>0,[pv_b|20]*[pv_l|20]+[pv_w|20],"")'
|
||||
cp = _col(doc, "pp_cp|파형강관|1000")["식"]
|
||||
assert "[pp_len|파형강관|1000]" in cp and "[$파형강관_1본_m]" in cp
|
||||
assert "식" not in _col(doc, "pp_cp|흄관|1000") # 흄관 1본 길이 빈칸 → 계산 안 함
|
||||
assert _col(doc, "bm_area|메쌓기|40~60|2.5")["식"] == (
|
||||
'IF([bm_len|메쌓기|40~60|2.5]>0,[bm_len|메쌓기|40~60|2.5]*2.5,"")'
|
||||
)
|
||||
|
||||
|
||||
def test_측점_줄_같은_측점은_한_줄_관은_나눔(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
rows = {row["id"]: row for row in doc["줄"]}
|
||||
assert doc["줄"][0]["고정"] == "전구간"
|
||||
interval = rows["s80.00~90.00"]["값"]
|
||||
assert interval["sta"] == "NO.4~NO.4+10"
|
||||
assert interval["ms|찰쌓기|2.5"] == 10 and interval["ms|메쌓기|2.0"] == 10
|
||||
first, second = rows["s85.05"]["값"], rows["s85.05#1"]["값"]
|
||||
assert first["sta"] == second["sta"] == "NO.4+5.05"
|
||||
assert first["pp_len|파형강관|1000"] == 14.0 and "pp_len|흄관|1000" not in first
|
||||
assert second["pp_len|흄관|1000"] == 14.0 # 한 측점 횡단 관 연장은 하나 — 두 관이 같이 씀
|
||||
assert first["rv|돌쌓기(찰)|-"] == second["rv|돌쌓기(찰)|-"] == 10
|
||||
assert rows["s300.00"]["값"]["ps"] == 1
|
||||
assert [row["값"].get("no") for row in doc["줄"][1:]] == [str(i) for i in range(1, 8)]
|
||||
assert rows["s200.00~230.00"]["식"]["pv_jt|20"].count("/5)") == 1 # 줄눈 간격 5 박음
|
||||
assert doc["알림"] == []
|
||||
bare = fill.fill_table(project, _master(), {})
|
||||
assert bare["알림"] == ["관 연장 없음 2곳 — B06 횡단 설계 전이면 빈칸"]
|
||||
assert not any("pp_len" in key for row in bare["줄"] for key in row["값"])
|
||||
|
||||
|
||||
def test_손_값과_전구간_줄은_다시_채워도_지킴(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
doc["줄"][0]["값"]["ps"] = 3
|
||||
next(row for row in doc["줄"] if row["id"] == "s300.00")["값"]["h_intake"] = 2
|
||||
doc["줄"].append({"id": "u1", "값": {"sta": "NO.9", "h_rockfall": 4}, "손": True})
|
||||
doc["줄"].append({"id": "s999.00", "값": {"sta": "NO.49+19", "h_waste": 1.5}})
|
||||
again = _filled(project, doc)
|
||||
rows = {row["id"]: row for row in again["줄"]}
|
||||
assert rows["all"]["값"]["ps"] == 3
|
||||
assert rows["s300.00"]["값"]["h_intake"] == 2
|
||||
assert rows["u1"]["값"]["h_rockfall"] == 4
|
||||
assert rows["s999.00"]["손"] is True and rows["s999.00"]["값"]["h_waste"] == 1.5
|
||||
assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]] # 두 번 펼치지 않음
|
||||
|
||||
|
||||
def test_측점_표기() -> None:
|
||||
assert fill.station_label(0) == "NO.0"
|
||||
assert fill.station_label(38) == "NO.1+18"
|
||||
assert fill.station_label(59.999) == "NO.3"
|
||||
assert fill.station_label(2413.5) == "NO.120+13.5"
|
||||
|
||||
|
||||
def test_저장은_양식과_손_값만_다시_채우면_제자리(project: Path) -> None:
|
||||
master = _master()
|
||||
doc = _filled(project)
|
||||
rows = {row["id"]: row for row in doc["줄"]}
|
||||
rows["s300.00"]["값"]["h_intake"] = 2
|
||||
rows["s85.05#1"]["값"]["memo"] = "흄관 확인"
|
||||
doc["줄"][0]["값"]["ps"] = 3
|
||||
saved = fill.strip_design({**doc, "결과": {"계산": {}}})
|
||||
assert [c["id"] for c in saved["열"]] == [
|
||||
c["id"] for c in master["열"]
|
||||
] # 펼침 규칙 열로 되돌림
|
||||
assert "알림" not in saved and "결과" not in saved
|
||||
assert saved["줄"] == [
|
||||
{"id": "all", "값": {"sta": "전구간", "ps": 3}, "고정": "전구간"},
|
||||
{"id": "s85.05#1", "값": {"sta": "NO.4+5.05", "memo": "흄관 확인"}},
|
||||
{"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}},
|
||||
]
|
||||
again = {row["id"]: row for row in _filled(project, saved)["줄"]}
|
||||
assert again["s300.00"]["값"]["h_intake"] == 2 and again["s300.00"]["값"]["ps"] == 1
|
||||
assert again["s85.05#1"]["값"]["memo"] == "흄관 확인"
|
||||
assert again["s85.05#1"]["값"]["pp_len|흄관|1000"] == 14.0
|
||||
|
||||
|
||||
def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path) -> None:
|
||||
saved = fill.strip_design(_filled(project))
|
||||
saved["줄"].append({"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}})
|
||||
saved["줄"].append({"id": "s555.00", "값": {"sta": "NO.27+15", "h_intake": 1}})
|
||||
doc = _filled(project, saved)
|
||||
last = doc["줄"][-1]
|
||||
assert last["id"] == "s555.00" and last["손"] is True
|
||||
assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"}
|
||||
kept = fill.strip_design(doc)
|
||||
assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음
|
||||
@@ -0,0 +1,404 @@
|
||||
"""M02 양식 층 — 자리 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 (임시 storage)."""
|
||||
|
||||
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 import common_util_storage
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
P1 = "11111111-1111-1111-1111-111111111111"
|
||||
P2 = "22222222-2222-2222-2222-222222222222"
|
||||
P_OTHER = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
storage = tmp_path / "storage"
|
||||
storage.mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
monkeypatch.setattr(common_util_storage, "STORAGE_BASE_DIR", str(storage))
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path / "master_template")
|
||||
layers.write_template(layers.system_dir(), "table", "구조물집계표", {"판": 1, "열": []})
|
||||
layers.write_template(layers.system_dir(), "drawing", "A1_도각", {"format": 6})
|
||||
|
||||
projects = {
|
||||
P1: {"id": P1, "name": "첫째", "company_id": 7, "user_id": 42},
|
||||
P2: {"id": P2, "name": "둘째", "company_id": 7, "user_id": 43},
|
||||
P_OTHER: {"id": P_OTHER, "name": "남의 회사", "company_id": 9, "user_id": 90},
|
||||
}
|
||||
for row in projects.values():
|
||||
row["storage_path"] = f"storage/{row['company_id']}/{row['user_id']}/{row['id']}"
|
||||
root = Path(common_util_storage.resolve_stored_project_path(row["storage_path"]))
|
||||
layers.seed_project(root)
|
||||
users = {42: 7, 43: 7, 90: 9}
|
||||
|
||||
async def project_row(project_id: str) -> dict[str, Any] | None:
|
||||
found = projects.get(str(project_id))
|
||||
return dict(found) if found else None
|
||||
|
||||
async def user_company(user_id: int) -> int | None:
|
||||
return users.get(user_id)
|
||||
|
||||
async def company_users(company_id: int) -> list[dict[str, Any]]:
|
||||
return [{"user_id": u, "name": f"u{u}"} for u, c in users.items() if c == company_id]
|
||||
|
||||
async def company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"project_id": p["id"], "name": p["name"], "storage_path": p["storage_path"]}
|
||||
for p in projects.values()
|
||||
if p["company_id"] == company_id
|
||||
]
|
||||
|
||||
monkeypatch.setattr(router_module, "_project_row", project_row)
|
||||
monkeypatch.setattr(router_module, "_user_company", user_company)
|
||||
monkeypatch.setattr(router_module, "_company_users", company_users)
|
||||
monkeypatch.setattr(router_module, "_company_projects", company_projects)
|
||||
|
||||
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "session": session, "storage": storage}
|
||||
|
||||
|
||||
def _root(world: dict[str, Any], project_id: str, company: int = 7, user: int = 42) -> Path:
|
||||
return (world["storage"] / str(company) / str(user) / project_id).resolve()
|
||||
|
||||
|
||||
def _url(project_id: str = P1) -> str:
|
||||
return f"/api/m02/layers/project/templates/table/구조물집계표?project_id={project_id}"
|
||||
|
||||
|
||||
def test_프로젝트_만들기_복사는_작업본과_초기본_둘에_manifest(world: dict[str, Any]) -> None:
|
||||
root = _root(world, P1)
|
||||
assert (root / "templates/table/구조물집계표.json").is_file()
|
||||
assert (root / "templates/_initial/drawing/A1_도각.json").is_file()
|
||||
entry = layers.read_manifest(root / "templates")["table/구조물집계표"]
|
||||
assert entry["층"] == "system" and entry["판"] == layers.version_of(
|
||||
layers.system_dir() / "table/구조물집계표.json"
|
||||
)
|
||||
|
||||
|
||||
def test_옛_프로젝트_넣기는_더하기만(world: dict[str, Any]) -> None:
|
||||
root = _root(world, P1)
|
||||
layers.write_template(root / "templates", "table", "구조물집계표", {"고침": True})
|
||||
assert layers.seed_project(root, only_missing=True) == {"작업본": [], "초기": []}
|
||||
assert layers.read_template(root / "templates", "table", "구조물집계표")["문서"] == {
|
||||
"고침": True
|
||||
}
|
||||
|
||||
|
||||
def test_작업본_저장_판_다르면_409_초기화는_초기본으로(world: dict[str, Any]) -> None:
|
||||
client = world["client"]
|
||||
got = client.get(_url()).json()
|
||||
assert got["층"] == "project" and got["문서"]["판"] == 1
|
||||
saved = client.put(_url(), json={"판": got["판"], "문서": {"판": 2, "열": []}})
|
||||
assert saved.status_code == 200
|
||||
stale = client.put(_url(), json={"판": got["판"], "문서": {"판": 3, "열": []}})
|
||||
assert stale.status_code == 409
|
||||
reset = client.post(f"/api/m02/projects/{P1}/templates/reset", json={})
|
||||
assert "table/구조물집계표" in reset.json()["초기화"]
|
||||
assert client.get(_url()).json()["문서"] == {"판": 1, "열": []}
|
||||
initial = _root(world, P1) / "templates/_initial/table/구조물집계표.json"
|
||||
assert layers.read_template(initial.parent.parent, "table", "구조물집계표")["문서"]["판"] == 1
|
||||
|
||||
|
||||
def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict[str, Any]) -> None:
|
||||
client, session = world["client"], world["session"]
|
||||
client.put(_url(), json={"판": client.get(_url()).json()["판"], "문서": {"회사": 1, "열": []}})
|
||||
body = {"to": "company", "종류": "table", "이름": "구조물집계표"}
|
||||
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 403
|
||||
session["role"] = "ADMIN"
|
||||
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
|
||||
applied = client.post(f"/api/m02/projects/{P2}/templates/apply", json={"from": "company"})
|
||||
assert applied.status_code == 200
|
||||
root2 = _root(world, P2, user=43)
|
||||
assert layers.read_template(root2 / "templates", "table", "구조물집계표")["문서"] == {
|
||||
"회사": 1,
|
||||
"열": [],
|
||||
}
|
||||
assert (
|
||||
layers.read_template(root2 / "templates/_initial", "table", "구조물집계표")["문서"]["판"]
|
||||
== 1
|
||||
)
|
||||
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
|
||||
|
||||
|
||||
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
|
||||
client, session = world["client"], world["session"]
|
||||
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
|
||||
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
|
||||
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
|
||||
assert client.get(mine).status_code == 200
|
||||
session.update(user_id=43)
|
||||
theirs = mine + "&user_id=42"
|
||||
assert client.get(theirs).status_code == 200
|
||||
put = client.put(
|
||||
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()
|
||||
got = client.post(
|
||||
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
|
||||
)
|
||||
assert got.status_code == 200
|
||||
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
|
||||
far = client.post(
|
||||
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
|
||||
)
|
||||
assert far.status_code == 403
|
||||
|
||||
|
||||
def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str, Any]) -> None:
|
||||
client = world["client"]
|
||||
ok = client.post(
|
||||
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P2}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
bad = client.post(
|
||||
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P_OTHER}
|
||||
)
|
||||
assert bad.status_code == 403
|
||||
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
|
||||
assert [row["project_id"] for row in sources["project"]] == [P2]
|
||||
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
|
||||
|
||||
|
||||
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
|
||||
client = world["client"]
|
||||
assert client.get("/api/m02/layers/system/templates").json()["양식"]
|
||||
put = client.put(
|
||||
"/api/m02/layers/system/templates/table/구조물집계표", json={"판": None, "문서": {}}
|
||||
)
|
||||
assert put.status_code == 403
|
||||
for name in ("..", "_initial", "a.b/c"):
|
||||
with pytest.raises(ValueError):
|
||||
layers.template_path(layers.system_dir(), "table", name)
|
||||
|
||||
|
||||
def test_채운_표는_작업본을_채워_돌려주고_저장_안_함(
|
||||
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def designs(project_id: str) -> list[dict[str, Any]]:
|
||||
return [{"chainage_m": 40.0, "design": {"pipe_length_m": 12}}]
|
||||
|
||||
monkeypatch.setattr(router_module, "_cross_designs", designs)
|
||||
monkeypatch.setattr(router_module.fill, "recalc", lambda document: {"계산": {}})
|
||||
root = _root(world, P1)
|
||||
master = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
layers.write_template(
|
||||
root / "templates", "table", "구조물집계표", json.loads(master.read_text(encoding="utf-8"))
|
||||
)
|
||||
edits = root / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": [{"chainage_m": 40.0, "source": "user"}]}), encoding="utf-8"
|
||||
)
|
||||
before = layers.version_of(root / "templates/table/구조물집계표.json")
|
||||
got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json()
|
||||
row = next(r for r in got["문서"]["줄"] if r["id"] == "s40.00")
|
||||
assert row["값"]["sta"] == "NO.2" and row["값"]["pp_len|파형강관|1000"] == 12
|
||||
assert got["결과"] == {"계산": {}} and got["판"] == before
|
||||
assert layers.version_of(root / "templates/table/구조물집계표.json") == before
|
||||
|
||||
|
||||
def test_프로젝트_만들기_자리에서_시스템_양식을_복사(world: dict[str, Any], tmp_path: Path) -> None:
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import _initialize_project_storage
|
||||
|
||||
layers.write_template(layers.company_dir(7), "table", "구조물집계표", {"회사": 1})
|
||||
root = tmp_path / "new_project"
|
||||
_initialize_project_storage(root, "new")
|
||||
got = layers.read_template(root / "templates", "table", "구조물집계표")
|
||||
assert got["문서"] == {"판": 1, "열": []} # 회사 공식이 있어도 시스템
|
||||
assert (root / "templates/_initial/drawing/A1_도각.json").is_file()
|
||||
assert (root / "project_manifest.json").is_file()
|
||||
|
||||
|
||||
def test_옛_프로젝트_넣기_도구는_더하기만_폴더_없으면_건너뜀(world: dict[str, Any]) -> None:
|
||||
from M02_MasterTemplete import M02_Template_Migrate as migrate
|
||||
|
||||
old = world["storage"] / "7/42/old-project"
|
||||
(old / "B05_Profile").mkdir(parents=True)
|
||||
(old / "B05_Profile/keep.txt").write_text("x", encoding="utf-8")
|
||||
root1 = _root(world, P1)
|
||||
layers.write_template(root1 / "templates", "table", "구조물집계표", {"고침": 1})
|
||||
(root1 / "templates/drawing/A1_도각.json").unlink()
|
||||
projects = [
|
||||
{
|
||||
"id": "old",
|
||||
"name": "옛",
|
||||
"company_id": 7,
|
||||
"user_id": 42,
|
||||
"storage_path": "storage/7/42/old-project",
|
||||
},
|
||||
{
|
||||
"id": P1,
|
||||
"name": "첫째",
|
||||
"company_id": 7,
|
||||
"user_id": 42,
|
||||
"storage_path": f"storage/7/42/{P1}",
|
||||
},
|
||||
{
|
||||
"id": "gone",
|
||||
"name": "없음",
|
||||
"company_id": 7,
|
||||
"user_id": 42,
|
||||
"storage_path": "storage/7/42/gone",
|
||||
},
|
||||
]
|
||||
dry = migrate.migrate(projects, dry_run=True)
|
||||
assert not (old / "templates").exists() and dry[0]["상태"] == "넣을 것(시험)"
|
||||
report = {row["id"]: row for row in migrate.migrate(projects)}
|
||||
assert report["old"]["상태"] == "넣음" and (old / "templates/_initial/table").is_dir()
|
||||
assert (old / "B05_Profile/keep.txt").read_text(encoding="utf-8") == "x"
|
||||
assert report[P1]["작업본"] == ["drawing/A1_도각"]
|
||||
assert layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"] == {"고침": 1}
|
||||
assert report["gone"]["상태"].startswith("폴더 없음")
|
||||
assert not (world["storage"] / "7/42/gone").exists()
|
||||
assert migrate.migrate(projects)[0]["상태"] == "이미 있음"
|
||||
assert "| 7 | 42 | 옛 |" in migrate.render(list(report.values()), [])
|
||||
|
||||
|
||||
def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dict[str, Any]) -> None:
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
|
||||
client = world["client"]
|
||||
master = json.loads(
|
||||
(
|
||||
config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
filled = fill.fill_document(
|
||||
master,
|
||||
[
|
||||
{
|
||||
"type_id": "position_sign",
|
||||
"placement": "point",
|
||||
"chainage_m": 40.0,
|
||||
"start_m": None,
|
||||
"end_m": None,
|
||||
"options": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
filled["줄"][1]["값"]["h_intake"] = 2
|
||||
version = client.get(_url()).json()["판"]
|
||||
assert client.put(_url(), json={"판": version, "문서": filled}).status_code == 200
|
||||
stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"]
|
||||
assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}}
|
||||
assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"])
|
||||
|
||||
|
||||
def test_옛_표_틀은_새_판으로_손_값은_지킴(world: dict[str, Any]) -> None:
|
||||
from M02_MasterTemplete import M02_Template_Migrate as migrate
|
||||
|
||||
new = {
|
||||
"판": 2,
|
||||
"열": [{"id": "a", "식": "1", "펼침틀": {"묶음": "g"}}],
|
||||
"줄": [],
|
||||
"변수": {"k": 1},
|
||||
}
|
||||
old = {
|
||||
"판": 1,
|
||||
"열": [{"id": "a", "식": "1", "바인딩": {"종류": "pipe", "값": "식"}}, {"id": "memo"}],
|
||||
"줄": [{"id": "s10.00", "값": {"sta": "NO.0+10", "memo": "손"}}],
|
||||
"변수": {"k": 5},
|
||||
}
|
||||
layers.write_template(layers.system_dir(), "table", "구조물집계표", new)
|
||||
root2, root1 = _root(world, P2, user=43), _root(world, P1)
|
||||
for folder in (root1 / "templates", root1 / "templates/_initial", root2 / "templates"):
|
||||
layers.write_template(folder, "table", "구조물집계표", old)
|
||||
layers.write_template(
|
||||
root2 / "templates", "table", "구조물집계표", {**old, "줄": [], "변수": {"k": 1}}
|
||||
)
|
||||
|
||||
assert migrate.refresh_tables(root1, dry_run=True) == [
|
||||
"templates/table/구조물집계표",
|
||||
"_initial/table/구조물집계표",
|
||||
]
|
||||
migrate.refresh_tables(root1)
|
||||
work = layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"]
|
||||
assert work["열"] == new["열"] and work["변수"] == {"k": 5} # 틀만 새 판 · 변수 · 손 값 지킴
|
||||
assert work["줄"] == old["줄"]
|
||||
system_version = layers.version_of(layers.system_dir() / "table/구조물집계표.json")
|
||||
initial = root1 / "templates/_initial"
|
||||
assert layers.version_of(initial / "table/구조물집계표.json") != system_version # 변수 다름
|
||||
assert layers.read_manifest(root1 / "templates")["table/구조물집계표"]["판"] == system_version
|
||||
assert migrate.refresh_tables(root1) == [] # 다시 돌려도 그대로
|
||||
# 손댄 것 없는 작업본은 시스템 파일 그대로(판이 같음)
|
||||
migrate.refresh_tables(root2)
|
||||
assert layers.version_of(root2 / "templates/table/구조물집계표.json") == system_version
|
||||
|
||||
|
||||
def test_뼈대_없는_문서는_400으로_거절하고_파일을_안_씀(world: dict[str, Any]) -> None:
|
||||
client = world["client"]
|
||||
path = _root(world, P1) / "templates/drawing/A1_도각.json"
|
||||
before = path.read_bytes()
|
||||
url = f"/api/m02/layers/project/templates/drawing/A1_도각?project_id={P1}"
|
||||
version = layers.version_of(path)
|
||||
for document in ({}, {"format": 6}, {"entities": {}}):
|
||||
got = client.put(url, json={"판": version, "문서": document})
|
||||
assert got.status_code == 400 and "entities" in got.json()["detail"]
|
||||
assert path.read_bytes() == before
|
||||
table = _url()
|
||||
assert client.put(table, json={"판": None, "문서": {}}).status_code == 400
|
||||
assert client.put(url, json={"판": version, "문서": {"entities": []}}).status_code == 200
|
||||
|
||||
|
||||
def _mine(name: str = "내양식", layer: str = "personal") -> str:
|
||||
return f"/api/m02/layers/{layer}/templates/table/{name}?project_id={P1}"
|
||||
|
||||
|
||||
def test_개인_회사_양식_지우기_권한_판_이름_예외(world: dict[str, Any]) -> None:
|
||||
client, session = world["client"], world["session"]
|
||||
doc = {"열": []}
|
||||
for layer in ("personal", "company"):
|
||||
client.put(_mine(layer=layer), json={"판": None, "문서": doc})
|
||||
personal = layers.personal_dir(7, 42) / "table/내양식.json"
|
||||
company = layers.company_dir(7) / "table/내양식.json"
|
||||
assert personal.is_file() and not company.is_file() # 일반 사용자 회사 저장은 403
|
||||
version = layers.version_of(personal)
|
||||
# 시스템 · 프로젝트 층은 거절
|
||||
for layer in ("system", "project"):
|
||||
assert client.delete(_mine("구조물집계표", layer) + f"&판={version}").status_code == 403
|
||||
assert (_root(world, P1) / "templates/table/구조물집계표.json").is_file()
|
||||
# 이름 예외 · 판 없음 · 판 다름 · 없는 양식
|
||||
for bad in ("%2E%2E", "a%2E%2Eb", "a%2Fb", "a%5Cb", "_initial", ".x", "a*b"):
|
||||
got = client.delete(_mine(bad) + f"&판={version}")
|
||||
assert got.status_code in (400, 404) and personal.is_file(), bad
|
||||
assert client.delete(_mine("a%2Fb") + f"&판={version}").status_code in (400, 404)
|
||||
assert client.delete(_mine()).status_code == 400
|
||||
stale = client.delete(_mine() + "&판=0000000000000000")
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["판"] == version
|
||||
assert client.delete(_mine("없는것") + f"&판={version}").status_code == 404
|
||||
# 다른 회사 사람은 못 지움 · 회사 층은 관리자만
|
||||
assert client.delete(_mine("내양식", "company") + f"&판={version}").status_code == 403
|
||||
# 지움 · 연달아 누르면 둘째는 404 · manifest 줄도 없어짐
|
||||
assert client.delete(_mine() + f"&판={version}").status_code == 200
|
||||
assert not personal.exists()
|
||||
assert client.delete(_mine() + f"&판={version}").status_code == 404
|
||||
session["role"] = "ADMIN"
|
||||
client.put(_mine(layer="company"), json={"판": None, "문서": doc})
|
||||
assert company.is_file()
|
||||
session.update(role="USER")
|
||||
assert (
|
||||
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
|
||||
== 403
|
||||
)
|
||||
session.update(role="ADMIN")
|
||||
assert (
|
||||
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
|
||||
== 200
|
||||
)
|
||||
assert "table/내양식" not in layers.read_manifest(layers.company_dir(7))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""표 쪽 나눔 — `ui_template_sheet_ops.ts` `pagePlan` 을 Node 로 바로 돌려 봄.
|
||||
|
||||
격자(`_render`)가 쪽을 이 계획대로 그림.
|
||||
|
||||
줄 120 문서 → project 쪽 3(50 · 50 · 20) · 쪽마다 머리(격자가 쪽마다 thead) · 합계는 마지막 쪽만 ·
|
||||
「전구간」 줄은 맨 위 · master 는 한 쪽.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
SCRIPT = """
|
||||
const { pagePlan } = await import(process.argv[1]);
|
||||
const doc = JSON.parse(process.argv[2]);
|
||||
const out = {};
|
||||
for (const mode of ["project", "master"]) {
|
||||
out[mode] = pagePlan(doc, mode).map((p) => ({
|
||||
ids: p.rows.map((r) => r.id), first: p.first, totals: p.totals,
|
||||
}));
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _plan(doc: dict) -> dict:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"--experimental-strip-types",
|
||||
"--no-warnings",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
SCRIPT,
|
||||
OPS.as_uri(),
|
||||
json.dumps(doc, ensure_ascii=False),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _doc(n: int, **head) -> dict:
|
||||
rows = [{"id": f"p{i}", "값": {}} for i in range(1, n)]
|
||||
rows.insert(7, {"id": "all", "값": {}, "고정": "전구간"}) # 문서 가운데 있어도 맨 위로
|
||||
return {"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": rows, **head}
|
||||
|
||||
|
||||
def test_120_rows_make_three_pages_totals_last():
|
||||
plan = _plan(_doc(120))["project"]
|
||||
assert [len(p["ids"]) for p in plan] == [50, 50, 20]
|
||||
assert [p["first"] for p in plan] == [1, 51, 101]
|
||||
assert [p["totals"] for p in plan] == [False, False, True]
|
||||
assert plan[0]["ids"][0] == "all"
|
||||
assert sum((p["ids"] for p in plan), []).count("all") == 1
|
||||
|
||||
|
||||
def test_page_rows_from_doc_and_master_single_page():
|
||||
got = _plan(_doc(55, 쪽줄=20))
|
||||
assert [len(p["ids"]) for p in got["project"]] == [20, 20, 15]
|
||||
assert [len(p["ids"]) for p in got["master"]] == [55]
|
||||
assert got["master"][0]["totals"] is True
|
||||
|
||||
|
||||
def test_empty_doc_still_one_page_with_totals():
|
||||
plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"]
|
||||
assert plan == [{"ids": [], "first": 1, "totals": True}]
|
||||
|
||||
|
||||
HEAD_SCRIPT = """
|
||||
const { setHeadLabel } = await import(process.argv[1]);
|
||||
const out = JSON.parse(process.argv[2]).map(([head, level, text, depth]) => {
|
||||
setHeadLabel(head, level, text, depth);
|
||||
return head;
|
||||
});
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_head_label_slash_splits_layers():
|
||||
cases = [
|
||||
[["새 열", None, None], 0, "관공/Φ800/관매설", 3],
|
||||
[["새 열", None, None], 0, "관공/Φ800", 3], # 적게 → 남은 층 합침
|
||||
[["새 열", None, None], 0, "a/b/c/d", 3], # 많으면 끝 층에 이어 붙임
|
||||
[["종류", "공법", "규격"], 1, "이름", 3], # / 없음 → 그 층만
|
||||
[["새 열"], 0, "가/나", 3], # 모자란 층은 채움
|
||||
]
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
|
||||
HEAD_SCRIPT, OPS.as_uri(), json.dumps(cases, ensure_ascii=False)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["관공", "Φ800", "관매설"],
|
||||
["관공", "Φ800", None],
|
||||
["a", "b", "c/d"],
|
||||
["종류", "이름", "규격"],
|
||||
["가", "나", None],
|
||||
]
|
||||
@@ -0,0 +1,215 @@
|
||||
"""표 양식 식 풀이 — 서버 Node 길(`common_util_sheet_recalc`) 그대로 시험.
|
||||
|
||||
풀이는 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌 · 화면과 서버가 같은 TS 를 씀.
|
||||
거울 — 파이썬 Decimal 로 따로 푼 답과 Node 답을 무작위 문서 여럿에서 견줌.
|
||||
⭐ 기준값 하나는 실무 엑셀 캐시값 — 울진 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」
|
||||
5~14줄 · 16줄 `=SUM(…)` 합계(openpyxl data_only 로 읽음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
from decimal import ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, getcontext
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util.common_util_sheet_recalc import recalc_sheet, recalc_sheets # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _doc(cols: list[dict], rows: list[dict], **head) -> dict:
|
||||
for col in cols:
|
||||
col.setdefault("머리", [col["id"]])
|
||||
return {"양식": "시험", "종류": "표", "판": 1, "열": cols, "줄": rows, **head}
|
||||
|
||||
|
||||
def _run(doc: dict) -> dict:
|
||||
out = recalc_sheet(doc)
|
||||
assert out is not None, "Node 번들 실행 실패"
|
||||
return out
|
||||
|
||||
|
||||
# ── 실무 캐시값 ──────────────────────────────────────────────────────────
|
||||
PRACTICE = { # 측점: {엑셀 열: 값} — 5~14줄
|
||||
"전구간": {"AC": 10},
|
||||
"71+13": {"D": 10, "F": 20, "G": 10, "H": 10, "AQ": 22, "AR": 3},
|
||||
"75+0": {"AF": 1},
|
||||
"78+7": {"F": 10, "H": 20, "AK": 18, "AL": 2, "AM": 1},
|
||||
"84+0": {"F": 10, "H": 20, "AK": 16, "AL": 2, "AN": 1},
|
||||
"89+10": {"E": 10, "H": 10, "AK": 18, "AL": 2, "AN": 1},
|
||||
"100+0": {"AF": 1},
|
||||
"102+0": {"F": 10, "H": 10, "AK": 16, "AL": 1, "AM": 1},
|
||||
"105+12": {"D": 10, "F": 10, "G": 10, "H": 10, "AO": 16, "AP": 2},
|
||||
"112+0": {"E": 10, "H": 10, "AK": 16, "AL": 2, "AN": 1},
|
||||
}
|
||||
PRACTICE_SUM = {"C": "0", "D": "20", "E": "20", "F": "60", "G": "20", "H": "90", "AC": "10"}
|
||||
PRACTICE_SUM |= {"AF": "2", "AK": "84", "AL": "9", "AM": "2", "AN": "3", "AO": "16"}
|
||||
PRACTICE_SUM |= {"AP": "2", "AQ": "22", "AR": "3", "S": "0", "U": "0"}
|
||||
|
||||
|
||||
def test_practice_totals_match_excel():
|
||||
names = sorted(PRACTICE_SUM)
|
||||
cols = [{"id": "B", "꼴": "글"}] + [{"id": n} for n in names if n not in ("S", "U")]
|
||||
cols += [{"id": "Q"}, {"id": "R"}]
|
||||
cols += [{"id": "S", "식": "[Q]*[R]"}, {"id": "U", "식": "INT([R]/6.00000001)*[Q]"}]
|
||||
rows = [
|
||||
{"id": f"r{i}", "값": {"B": sta, **vals}} for i, (sta, vals) in enumerate(PRACTICE.items())
|
||||
]
|
||||
doc = _doc(cols, rows, 합계줄=[{"id": "sum", "이름": "계", "식": "SUM"}])
|
||||
out = _run(doc)
|
||||
assert out["오류"] == []
|
||||
assert {k: out["합계"]["sum"][k] for k in PRACTICE_SUM} == PRACTICE_SUM # 엑셀에 있는 합 칸만
|
||||
assert out["계산"]["r0"] == {"S": "0", "U": "0"} # 엑셀 S5 · U5 = 0
|
||||
|
||||
|
||||
def test_pavement_joint_coupling_and_rounding():
|
||||
cols = [
|
||||
{"id": "b"},
|
||||
{"id": "l"},
|
||||
{"id": "a", "식": "[b]*[l]"},
|
||||
{"id": "jt", "식": "INT([l]/6.00000001)*[b]"},
|
||||
{"id": "cp", "식": "ROUNDUP([l]/[$본],0)-1"},
|
||||
{"id": "r", "식": "[l]/3", "끝수": {"자리": 2, "방법": "반올림"}},
|
||||
{"id": "up", "식": "[l]/3", "끝수": {"자리": 1, "방법": "올림"}},
|
||||
{"id": "dn", "식": "[b]*1.15*100", "끝수": {"자리": 0, "방법": "버림"}},
|
||||
]
|
||||
rows = [{"id": "r1", "값": {"b": 3, "l": 120}}, {"id": "r2", "값": {"b": "0.29", "l": 16}}]
|
||||
out = _run(_doc(cols, rows, 변수={"본": 8}))
|
||||
assert out["오류"] == []
|
||||
assert out["계산"]["r1"] == {
|
||||
"a": "360",
|
||||
"jt": "57", # 120/6.00000001 = 19.99… → 19 × 3
|
||||
"cp": "14", # 올림(120/8)−1
|
||||
"r": "40",
|
||||
"up": "40",
|
||||
"dn": "345",
|
||||
}
|
||||
# 부동소수면 틀어지는 자리 — 0.29×1.15×100 = 33.35 → 버림 33 · 16/3 = 5.333… → 올림 5.4
|
||||
assert out["계산"]["r2"] == {
|
||||
"a": "4.64",
|
||||
"jt": "0.58",
|
||||
"cp": "1",
|
||||
"r": "5.33",
|
||||
"up": "5.4",
|
||||
"dn": "33",
|
||||
}
|
||||
|
||||
|
||||
def test_row_override_total_map_and_cell_ref():
|
||||
cols = [{"id": "sta", "꼴": "글"}, {"id": "x"}, {"id": "y", "식": "[x]*2"}]
|
||||
rows = [
|
||||
{"id": "r1", "값": {"sta": "전구간", "x": 5}, "고정": "전구간"},
|
||||
{"id": "r2", "값": {"x": 7}, "식": {"y": "[x]+[x@r1]"}},
|
||||
]
|
||||
totals = [
|
||||
{"id": "sum", "이름": "계", "식": {"x": "SUM", "*": "MAX([y@r1],[y@r2])"}},
|
||||
{"id": "avg", "이름": "평균", "식": {"x": "ROUND([x@sum]/2,1)"}},
|
||||
]
|
||||
out = _run(_doc(cols, rows, 합계줄=totals))
|
||||
assert out["오류"] == []
|
||||
assert out["계산"] == {"r1": {"y": "10"}, "r2": {"y": "12"}}
|
||||
assert out["합계"] == {"sum": {"x": "12", "y": "12"}, "avg": {"x": "6"}}
|
||||
|
||||
|
||||
def test_errors_stay_in_their_cells():
|
||||
cols = [
|
||||
{"id": "x"},
|
||||
{"id": "p", "식": "[q]+1"},
|
||||
{"id": "q", "식": "[p]+1"},
|
||||
{"id": "z", "식": "[x]/0"},
|
||||
{"id": "n", "식": "[없음]+1"},
|
||||
{"id": "t", "식": "[x]*2"},
|
||||
{"id": "bad", "식": "[x]*("},
|
||||
]
|
||||
rows = [{"id": "r1", "값": {"x": "글자"}}, {"id": "r2", "값": {"x": 4}}]
|
||||
out = _run(_doc(cols, rows))
|
||||
why = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]}
|
||||
assert "돌고 도는 참조" in why.values()
|
||||
assert {("r2", "p"), ("r2", "q")} <= set(why)
|
||||
assert "0 으로 나눔" in why[("r2", "z")]
|
||||
assert "없는 열" in why[("r2", "n")]
|
||||
assert "글" in why[("r1", "t")]
|
||||
assert ("r2", "bad") in why
|
||||
assert out["계산"]["r2"] == {"t": "8"} # 나머지 칸은 계속 풂
|
||||
|
||||
|
||||
# ── 거울 — 파이썬 Decimal 로 따로 푼 답 ─────────────────────────────────
|
||||
getcontext().prec = 60
|
||||
TEMPLATES = {
|
||||
"mul": ("[a]*[b]", lambda a, b, k: a * b),
|
||||
"joint": (
|
||||
"INT([b]/6.00000001)*[a]",
|
||||
lambda a, b, k: (b / Decimal("6.00000001")).to_integral_value(ROUND_FLOOR) * a,
|
||||
),
|
||||
"ratio": (
|
||||
"ROUND([a]/[b]*100,2)",
|
||||
lambda a, b, k: (a / b * 100).quantize(Decimal("0.01"), ROUND_HALF_UP),
|
||||
),
|
||||
"band": (
|
||||
"ROUNDUP([a]/8,0)-1",
|
||||
lambda a, b, k: (a / 8).to_integral_value(ROUND_UP) - 1,
|
||||
),
|
||||
"down": (
|
||||
"ROUNDDOWN([a]*1.15,1)",
|
||||
lambda a, b, k: (a * Decimal("1.15")).quantize(Decimal("0.1"), ROUND_DOWN),
|
||||
),
|
||||
"mix": ("[a]-[b]*2+[$k]", lambda a, b, k: a - b * 2 + k),
|
||||
}
|
||||
|
||||
|
||||
def _plain(x: Decimal) -> str:
|
||||
text = format(x.normalize(), "f")
|
||||
return "0" if text in ("-0", "") else text
|
||||
|
||||
|
||||
def test_mirror_random_docs_match_decimal():
|
||||
rnd = random.Random(20260925)
|
||||
docs, expected = [], []
|
||||
for _ in range(40):
|
||||
k = Decimal(rnd.randint(-500, 500)) / 10
|
||||
rows, want = [], {}
|
||||
for i in range(rnd.randint(1, 12)):
|
||||
a = Decimal(rnd.randint(0, 99999)) / 100
|
||||
b = Decimal(rnd.randint(1, 99999)) / 100
|
||||
rows.append({"id": f"r{i}", "값": {"a": str(a), "b": str(b)}})
|
||||
want[f"r{i}"] = {name: fn(a, b, k) for name, (_, fn) in TEMPLATES.items()}
|
||||
cols = [{"id": "a"}, {"id": "b"}] + [{"id": n, "식": f} for n, (f, _) in TEMPLATES.items()]
|
||||
docs.append(
|
||||
_doc(cols, rows, 변수={"k": str(k)}, 합계줄=[{"id": "s", "이름": "계", "식": "SUM"}])
|
||||
)
|
||||
sums = {n: sum((w[n] for w in want.values()), Decimal(0)) for n in TEMPLATES}
|
||||
expected.append((want, sums))
|
||||
results = recalc_sheets(docs)
|
||||
assert results is not None
|
||||
for out, (want, sums) in zip(results, expected, strict=True):
|
||||
assert out["오류"] == []
|
||||
assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()}
|
||||
for name, total in sums.items():
|
||||
assert out["합계"]["s"][name] == _plain(total)
|
||||
|
||||
|
||||
def test_text_in_number_column_is_flagged_and_skipped():
|
||||
doc = _doc(
|
||||
[{"id": "a"}, {"id": "n", "꼴": "글"}, {"id": "f", "식": "[a]*2"}],
|
||||
[
|
||||
{"id": "r1", "값": {"a": 5, "n": "abc"}},
|
||||
{"id": "r2", "값": {"a": "abc", "n": "x"}},
|
||||
{"id": "r3", "값": {"a": "1,000"}},
|
||||
{"id": "r4", "값": {"a": ""}},
|
||||
],
|
||||
합계줄=[{"id": "t", "이름": "합계", "식": "SUM"}],
|
||||
)
|
||||
out = _run(doc)
|
||||
assert out["합계"]["t"]["a"] == "1005" # abc 는 빼고 · 1,000 은 수 · 빈 칸은 0
|
||||
errors = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]}
|
||||
assert errors[("r2", "a")] == "수가 아님"
|
||||
assert errors[("t", "a")].startswith("수가 아닌 칸 1 개")
|
||||
# 글 칸 · 정상 수 칸은 표시 없음(r2 의 식 칸은 글을 곱해 식 오류 — 따로)
|
||||
assert not any(k[1] == "n" or k[0] in ("r1", "r3", "r4") for k in errors)
|
||||
@@ -0,0 +1,104 @@
|
||||
/* ui_template/cad_host — 웹캐드 iframe 칸 · 도각 편집 띠 (B07 · M02 공용). */
|
||||
|
||||
/* CAD 뷰어 호스트 (상세 페이지 영역) */
|
||||
.cad-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
|
||||
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
|
||||
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
|
||||
.cad-host[data-loading="true"]::after {
|
||||
content: "도면을 불러오는 중…";
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cad-host[data-error]:not([data-error=""])::before {
|
||||
content: attr(data-error);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 70%;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-danger, #d33);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-danger, #d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* B07 독립형 CAD 앱 임베드 */
|
||||
.cad-host__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cad-host__license {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
bottom: 7px;
|
||||
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cad-host__license:hover {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
|
||||
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
|
||||
.cad-frame-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.cad-frame-edit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cad-frame-edit__label {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cad-frame-edit__buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.cad-frame-edit__buttons > button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/* =============================================================================
|
||||
* ui_template/cad_host/cad_host.ts
|
||||
* 웹캐드(openwebcad) iframe 부모 쪽 한 벌 — B07 상세 설계 · M02 도면 양식이 함께 쓴다.
|
||||
*
|
||||
* iframe 띄우기 · ready 대기 · 시간초과 · 도면 싣기 · 저장 요청 · 토스트 중계를 맡는다.
|
||||
* 페이지마다 다른 일(도면 넘기기 · 내보내기 · 미저장 표시)은 콜백으로 받는다.
|
||||
* CAD 앱 쪽 짝은 `B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./cad_host.css";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
|
||||
/** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const CAD_APP_URL = "/b07-cad/index.html";
|
||||
|
||||
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
||||
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
||||
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
||||
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
||||
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
||||
const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save";
|
||||
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
||||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
|
||||
export interface CadHostDrawing {
|
||||
entities: Record<string, unknown>[];
|
||||
layers?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** CAD 저장 응답 (도면 + 수량표 — 수량표는 B07 횡단도만). */
|
||||
export interface CadSaveResult<D, Q> {
|
||||
drawing: D;
|
||||
quantityTable: Q | null;
|
||||
}
|
||||
|
||||
export interface CadHostOptions {
|
||||
/** iframe 제목(접근성). */
|
||||
title: string;
|
||||
/** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */
|
||||
onChanged?: (dirty: boolean) => void;
|
||||
/** CAD 리본의 이전·다음 도면 단추. */
|
||||
onNavigate?: (direction: "prev" | "next") => void;
|
||||
/** CAD 리본의 DXF·DWG 내보내기 단추. */
|
||||
onExport?: (fileFormat: "dxf" | "dwg") => void;
|
||||
/** CAD 안 💾 · Ctrl+S — `hostSave` 로 실은 화면(M02 양식)만 온다. */
|
||||
onHostSave?: () => void;
|
||||
}
|
||||
|
||||
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
|
||||
export interface CadLoadExtra {
|
||||
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
||||
hostTitle?: string;
|
||||
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡긴다 — 켜면 `onHostSave` 로 옴. */
|
||||
hostSave?: boolean;
|
||||
}
|
||||
|
||||
export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
|
||||
element: HTMLElement;
|
||||
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */
|
||||
beginLoading: () => void;
|
||||
/** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */
|
||||
fail: (detail: string) => void;
|
||||
/** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다.
|
||||
* frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
||||
* (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). */
|
||||
load: (
|
||||
drawing: D,
|
||||
meta: M | null,
|
||||
frameEdit?: boolean,
|
||||
frameFields?: Record<string, string>,
|
||||
extra?: CadLoadExtra,
|
||||
) => void;
|
||||
/** CAD 의 지금 편집본을 받는다. */
|
||||
requestSave: () => Promise<CadSaveResult<D, Q>>;
|
||||
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown>(
|
||||
options: CadHostOptions,
|
||||
): CadHost<D, M, Q> {
|
||||
const element = document.createElement("div");
|
||||
element.className = "cad-host";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "cad-host__frame";
|
||||
frame.src = CAD_APP_URL;
|
||||
frame.title = options.title;
|
||||
const license = document.createElement("a");
|
||||
license.className = "cad-host__license";
|
||||
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
||||
license.target = "_blank";
|
||||
license.rel = "noreferrer";
|
||||
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
||||
element.append(frame, license);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad:
|
||||
| {
|
||||
drawing: D;
|
||||
meta: M | null;
|
||||
frameEdit: boolean;
|
||||
frameFields: Record<string, string>;
|
||||
extra: CadLoadExtra;
|
||||
}
|
||||
| undefined;
|
||||
let resolveSave: ((payload: CadSaveResult<D, Q>) => void) | undefined;
|
||||
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
|
||||
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
|
||||
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
|
||||
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
|
||||
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
|
||||
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
|
||||
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
|
||||
const CAD_READY_TIMEOUT_MS = 20000;
|
||||
const CAD_LOAD_TIMEOUT_MS = 15000;
|
||||
let loadWatchdog: number | undefined;
|
||||
|
||||
const showFailure = (detail: string): void => {
|
||||
element.dataset.loading = "false";
|
||||
element.dataset.error = detail;
|
||||
showToast(detail, "error");
|
||||
};
|
||||
|
||||
const stopLoadWatchdog = (): void => {
|
||||
if (loadWatchdog === undefined) return;
|
||||
window.clearTimeout(loadWatchdog);
|
||||
loadWatchdog = undefined;
|
||||
};
|
||||
|
||||
const startLoadWatchdog = (): void => {
|
||||
stopLoadWatchdog();
|
||||
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
|
||||
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
|
||||
loadWatchdog = window.setTimeout(() => {
|
||||
loadWatchdog = undefined;
|
||||
if (element.dataset.loading !== "true") return;
|
||||
showFailure(
|
||||
cadReady
|
||||
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
|
||||
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
|
||||
);
|
||||
}, wait);
|
||||
};
|
||||
|
||||
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
|
||||
frame.addEventListener("error", () => {
|
||||
stopLoadWatchdog();
|
||||
showFailure("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
|
||||
});
|
||||
|
||||
const post = (message: Record<string, unknown>): void => {
|
||||
frame.contentWindow?.postMessage(message, window.location.origin);
|
||||
};
|
||||
|
||||
const load: CadHost<D, M, Q>["load"] = (
|
||||
drawing,
|
||||
meta,
|
||||
frameEdit = false,
|
||||
frameFields = {},
|
||||
extra = {},
|
||||
) => {
|
||||
pendingLoad = { drawing, meta, frameEdit, frameFields, extra };
|
||||
if (!cadReady) return;
|
||||
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
|
||||
pendingLoad = undefined;
|
||||
};
|
||||
|
||||
const requestSave = (): Promise<CadSaveResult<D, Q>> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
post({ type: CAD_SAVE_REQUEST_MESSAGE });
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
const onMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
drawing?: D;
|
||||
quantityTable?: Q | null;
|
||||
direction?: "prev" | "next";
|
||||
fileFormat?: "dxf" | "dwg";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
if (message.type === CAD_TOAST_MESSAGE) {
|
||||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
kind ?? "info",
|
||||
duration,
|
||||
actionId ? () => post({ type: CAD_TOAST_ACTION_MESSAGE, actionId }) : undefined,
|
||||
);
|
||||
} else if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) {
|
||||
const { drawing, meta, frameEdit, frameFields, extra } = pendingLoad;
|
||||
load(drawing, meta, frameEdit, frameFields, extra);
|
||||
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
|
||||
if (element.dataset.loading === "true") startLoadWatchdog();
|
||||
}
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
element.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
options.onChanged?.(message.dirty !== false);
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
options.onNavigate?.(message.direction);
|
||||
} else if (message.type === CAD_HOST_SAVE_MESSAGE) {
|
||||
options.onHostSave?.();
|
||||
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
||||
options.onExport?.(message.fileFormat);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
return {
|
||||
element,
|
||||
beginLoading: () => {
|
||||
element.dataset.loading = "true";
|
||||
element.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
||||
},
|
||||
fail: (detail) => {
|
||||
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
||||
showFailure(detail);
|
||||
},
|
||||
load,
|
||||
requestSave,
|
||||
destroy: () => {
|
||||
stopLoadWatchdog();
|
||||
window.removeEventListener("message", onMessage);
|
||||
},
|
||||
};
|
||||
}
|
||||
+38
-30
@@ -1,21 +1,27 @@
|
||||
/**
|
||||
* B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
|
||||
* 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
|
||||
* B07 에서 떼어 공용으로 둔다 — 도각을 읽고 쓰는 길(`api`)만 페이지가 넘긴다.
|
||||
*
|
||||
* 정본(`resources/template_2dDrawing/00_template_A1.json`)은 프로그램 기본 도각이라
|
||||
* 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고,
|
||||
* 이후 그리는 도면이 그것을 쓴다 (2026-09-01 사용자 확정).
|
||||
* 시스템 양식(`resources/master_template/drawing/00_template_A1.json`)은 건드리지 않는다.
|
||||
* 고친 도각은 **프로젝트 도각**(작업본 `{프로젝트}/templates/drawing/`)으로 저장되고,
|
||||
* 이후 그리는 도면이 그것을 쓴다 (PLAN 10-5).
|
||||
*
|
||||
* 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다시 그려진다.
|
||||
*/
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
type CadDrawing,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import type { CadHostDrawing } from "./cad_host";
|
||||
|
||||
/** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */
|
||||
export interface FrameTemplateApi<D extends CadHostDrawing> {
|
||||
/** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */
|
||||
fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record<string, string> }>;
|
||||
save: (drawing: D) => Promise<void>;
|
||||
/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */
|
||||
importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>;
|
||||
/** 고친 도각을 지우고 기본 도각으로 되돌린다. */
|
||||
reset: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FrameTemplateEditor {
|
||||
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
|
||||
@@ -26,18 +32,18 @@ export interface FrameTemplateEditor {
|
||||
isEditing: () => boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
projectId: string;
|
||||
interface Options<D extends CadHostDrawing> {
|
||||
api: FrameTemplateApi<D>;
|
||||
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
|
||||
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
|
||||
sendLoad: (
|
||||
drawing: CadDrawing,
|
||||
drawing: D,
|
||||
meta: null,
|
||||
frameEdit?: boolean,
|
||||
frameFields?: Record<string, string>,
|
||||
) => void;
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<CadDrawing>;
|
||||
requestCadDrawing: () => Promise<D>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
restoreDrawing: () => void;
|
||||
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
|
||||
@@ -46,16 +52,18 @@ interface Options {
|
||||
currentDrawingInfo: () => { label: string; number: string } | null;
|
||||
}
|
||||
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
export function createFrameTemplateEditor<D extends CadHostDrawing>(
|
||||
options: Options<D>,
|
||||
): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
|
||||
let frameFields: Record<string, string> = {};
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "b07-frame-edit";
|
||||
banner.className = "cad-frame-edit";
|
||||
banner.hidden = true;
|
||||
const label = document.createElement("span");
|
||||
label.className = "b07-frame-edit__label";
|
||||
label.className = "cad-frame-edit__label";
|
||||
banner.append(label);
|
||||
|
||||
const finishButton = createButton({
|
||||
@@ -64,7 +72,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
onClick: () => void finish(),
|
||||
});
|
||||
/**
|
||||
* 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설).
|
||||
* 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다 (2026-09-01 신설).
|
||||
* 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
*/
|
||||
const resetButton = createButton({
|
||||
@@ -75,7 +83,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
/**
|
||||
* 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가
|
||||
* 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직
|
||||
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
|
||||
*/
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
@@ -95,7 +103,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
onClick: () => leave(),
|
||||
});
|
||||
const bannerButtons = document.createElement("div");
|
||||
bannerButtons.className = "b07-frame-edit__buttons";
|
||||
bannerButtons.className = "cad-frame-edit__buttons";
|
||||
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
|
||||
banner.append(bannerButtons, fileInput);
|
||||
|
||||
@@ -118,7 +126,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
if (!file) return;
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
const response = await importFrameTemplate(options.projectId, file);
|
||||
const response = await options.api.importFile(file);
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
|
||||
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
|
||||
@@ -134,7 +142,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
|
||||
async function enter(): Promise<void> {
|
||||
try {
|
||||
const response = await fetchFrameTemplate(options.projectId);
|
||||
const response = await options.api.fetch();
|
||||
// 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다.
|
||||
const info = options.currentDrawingInfo();
|
||||
frameFields = {
|
||||
@@ -145,8 +153,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
button.disabled = true;
|
||||
banner.hidden = false;
|
||||
label.textContent = response.customized
|
||||
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
? "도각 편집 중 — 이 프로젝트에서 고친 도각입니다."
|
||||
: "도각 편집 중 — 고치면 이 프로젝트 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
@@ -156,16 +164,16 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
async function resetToDefault(): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" +
|
||||
"확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?",
|
||||
"이 프로젝트 도각을 처음 복사한 도각으로 되돌립니다.\n" +
|
||||
"확정하지 않은 도면부터 되돌린 도각으로 나옵니다. 계속할까요?",
|
||||
)
|
||||
)
|
||||
return;
|
||||
resetButton.disabled = true;
|
||||
try {
|
||||
await resetFrameTemplate(options.projectId);
|
||||
await options.api.reset();
|
||||
options.onSaved();
|
||||
showToast("기본 도각으로 되돌렸습니다.", "success");
|
||||
showToast("처음 복사한 도각으로 되돌렸습니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
@@ -182,7 +190,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
finishButton.disabled = true;
|
||||
try {
|
||||
const drawing = await options.requestCadDrawing();
|
||||
await saveFrameTemplate(options.projectId, drawing);
|
||||
await options.api.save(drawing);
|
||||
options.onSaved();
|
||||
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
|
||||
leave();
|
||||
@@ -0,0 +1,208 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.css — 엑셀처럼 도는 표 부품(`ui_template_sheet.ts`)
|
||||
* 색은 테마 토큰만 — 설계값 = 초록 · 계산 = 노랑 · 손 입력 = 보라 기운
|
||||
* ========================================================================== */
|
||||
|
||||
.ui-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ui-sheet__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-sheet__toolbar .ui-btn.is-on {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.ui-sheet__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ui-sheet__scroll {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ui-sheet__pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.ui-sheet__table {
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ui-sheet__page {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-sheet__table th,
|
||||
.ui-sheet__table td {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-sheet__table thead th,
|
||||
.ui-sheet__table thead td {
|
||||
background: var(--color-surface);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-sheet__table .ui-sheet__gutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
background: var(--color-surface) !important;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-sheet__table .is-frozen {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-bound {
|
||||
background: color-mix(in srgb, var(--color-success) 7%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-calc {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-hand {
|
||||
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-override {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ui-sheet__table .is-editable {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-selected {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ui-sheet__table tr.is-fixed td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__meta td {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-sheet__total td,
|
||||
.ui-sheet__total th {
|
||||
border-top: 2px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-sheet__clamp {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
.ui-sheet__chip {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-bound {
|
||||
background: color-mix(in srgb, var(--color-success) 18%, var(--color-canvas));
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-calc {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-hand {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-sheet__headhint {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.ui-sheet__resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ui-sheet__table td input,
|
||||
.ui-sheet__table th input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.ts
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
|
||||
*
|
||||
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
|
||||
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
||||
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 칸 고치기는 M01 `startEdit`(Enter · 칸 밖 = 확정 · Esc = 취소) 를 그대로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import {
|
||||
addColumn,
|
||||
addRow,
|
||||
canDeleteRow,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
type SheetMode,
|
||||
setCell,
|
||||
setColumnWidth,
|
||||
setHeadLabel,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { headDepth } from "./ui_template_sheet_header";
|
||||
import { recalcSheet } from "./ui_template_sheet_recalc";
|
||||
import {
|
||||
KEY_UNIT,
|
||||
keyEditable,
|
||||
navRows,
|
||||
type RenderState,
|
||||
renderSheet,
|
||||
} from "./ui_template_sheet_render";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
import "./ui_template_sheet.css";
|
||||
|
||||
export type { SheetMode } from "./ui_template_sheet_ops";
|
||||
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
|
||||
export interface SheetOptions {
|
||||
mode: SheetMode;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
}
|
||||
|
||||
export interface SheetHandle {
|
||||
getDoc(): SheetDoc;
|
||||
setDoc(doc: SheetDoc): void;
|
||||
recalc(): SheetResult;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 32;
|
||||
|
||||
export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptions): SheetHandle {
|
||||
const state: RenderState = {
|
||||
doc: structuredClone(input),
|
||||
mode: opts.mode,
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
};
|
||||
const scroll = el("div", { className: "ui-sheet__scroll" });
|
||||
const toolbar = el("div", { className: "ui-sheet__toolbar" });
|
||||
const root = el("div", {
|
||||
className: `ui-sheet ui-sheet--${opts.mode}`,
|
||||
attrs: { tabindex: "0" },
|
||||
children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll],
|
||||
});
|
||||
host.replaceChildren(root);
|
||||
|
||||
const colOf = (id: string): SheetColumn | undefined => state.doc.열.find((c) => c.id === id);
|
||||
const rowIdOf = (key: string | undefined): string | null =>
|
||||
key?.startsWith("d:") ? key.slice(2) : null;
|
||||
|
||||
// ── 단추 ──────────────────────────────────────────────────────────
|
||||
const button = (label: string, run: () => void): HTMLButtonElement =>
|
||||
createButton({ label, variant: "ghost", onClick: run });
|
||||
const rowAdd = button(st("Row_Add"), () => {
|
||||
const id = addRow(state.doc, state.mode, rowIdOf(state.sel?.r));
|
||||
state.sel = { r: `d:${id}`, c: state.sel?.c ?? state.doc.열[0]?.id ?? "" };
|
||||
changed();
|
||||
});
|
||||
const rowDelete = button(st("Row_Delete"), () => {
|
||||
const id = rowIdOf(state.sel?.r);
|
||||
if (!id) return;
|
||||
const keys = navRows(state.doc, state.mode);
|
||||
deleteRow(state.doc, id);
|
||||
const next = keys[keys.indexOf(`d:${id}`) + 1] ?? keys[keys.indexOf(`d:${id}`) - 1];
|
||||
state.sel = next && state.sel ? { r: next, c: state.sel.c } : null;
|
||||
changed();
|
||||
});
|
||||
const colAdd = button(st("Col_Add"), () => {
|
||||
const id = addColumn(state.doc, state.sel?.c ?? null, st("Col_New"));
|
||||
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
|
||||
changed();
|
||||
});
|
||||
const colDelete = button(st("Col_Delete"), () => {
|
||||
const id = state.sel?.c;
|
||||
if (!id || state.doc.열.length <= 1) return;
|
||||
const at = state.doc.열.findIndex((c) => c.id === id);
|
||||
deleteColumn(state.doc, id);
|
||||
const next = state.doc.열[Math.min(at, state.doc.열.length - 1)];
|
||||
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
|
||||
changed();
|
||||
});
|
||||
const colHand = button(st("Col_Hand"), () => {
|
||||
const col = state.sel && colOf(state.sel.c);
|
||||
if (!col) return;
|
||||
if (col.손) delete col.손;
|
||||
else col.손 = true;
|
||||
changed();
|
||||
});
|
||||
toolbar.append(rowAdd, rowDelete);
|
||||
if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand);
|
||||
|
||||
const syncToolbar = (): void => {
|
||||
const rowId = rowIdOf(state.sel?.r);
|
||||
const row = state.doc.줄.find((r) => r.id === rowId);
|
||||
rowDelete.disabled = !row || !canDeleteRow(state.mode, row);
|
||||
const col = state.sel ? colOf(state.sel.c) : undefined;
|
||||
colDelete.disabled = !col || state.doc.열.length <= 1;
|
||||
colHand.disabled = !col;
|
||||
colHand.classList.toggle("is-on", !!col?.손);
|
||||
};
|
||||
|
||||
// ── 그리기 · 고름 ─────────────────────────────────────────────────
|
||||
const render = (): void => {
|
||||
root.querySelector(".ui-sheet__headhint")?.remove();
|
||||
state.result = recalcSheet(state.doc);
|
||||
const { scrollLeft, scrollTop } = scroll;
|
||||
scroll.replaceChildren(renderSheet(state));
|
||||
scroll.scrollLeft = scrollLeft;
|
||||
scroll.scrollTop = scrollTop;
|
||||
syncToolbar();
|
||||
};
|
||||
const changed = (): void => {
|
||||
render();
|
||||
opts.onChange?.(structuredClone(state.doc));
|
||||
};
|
||||
|
||||
const cellsAt = (r: string, c: string): HTMLElement[] => [
|
||||
...scroll.querySelectorAll<HTMLElement>(
|
||||
`td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`,
|
||||
),
|
||||
];
|
||||
const select = (r: string, c: string, reveal = false): void => {
|
||||
for (const cell of scroll.querySelectorAll(".is-selected"))
|
||||
cell.classList.remove("is-selected");
|
||||
state.sel = { r, c };
|
||||
const cells = cellsAt(r, c);
|
||||
for (const cell of cells) cell.classList.add("is-selected");
|
||||
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
syncToolbar();
|
||||
};
|
||||
const move = (dr: number, dc: number): void => {
|
||||
const keys = navRows(state.doc, state.mode);
|
||||
const cols = state.doc.열;
|
||||
if (!state.sel) {
|
||||
if (keys.length && cols.length) select(keys[0], cols[0].id, true);
|
||||
return;
|
||||
}
|
||||
const r = Math.max(0, Math.min(keys.length - 1, keys.indexOf(state.sel.r) + dr));
|
||||
const at = cols.findIndex((c) => c.id === state.sel!.c);
|
||||
const c = Math.max(0, Math.min(cols.length - 1, at + dc));
|
||||
select(keys[r], cols[c].id, true);
|
||||
};
|
||||
|
||||
// ── 칸 고치기 ─────────────────────────────────────────────────────
|
||||
const rawText = (key: string, col: SheetColumn): string => {
|
||||
if (key === KEY_UNIT) return col.단위 ?? "";
|
||||
if (key === "s:formula") return col.식 ?? "";
|
||||
if (key === "s:desc") return col.설명 ?? "";
|
||||
if (key === "s:price") return col.일위대가 ?? "";
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
if (formula) return `=${formula}`;
|
||||
const raw = row?.값[col.id];
|
||||
return raw === null || raw === undefined ? "" : String(raw);
|
||||
};
|
||||
const write = (key: string, col: SheetColumn, text: string): void => {
|
||||
const value = text.trim();
|
||||
if (key === KEY_UNIT) col.단위 = value || null;
|
||||
else if (key === "s:formula") {
|
||||
if (value) col.식 = value;
|
||||
else delete col.식;
|
||||
} else if (key === "s:desc") col.설명 = value;
|
||||
else if (key === "s:price") col.일위대가 = value || null;
|
||||
else setCell(state.doc, key.slice(2), col, text);
|
||||
changed();
|
||||
};
|
||||
const edit = (initial?: string): void => {
|
||||
const sel = state.sel;
|
||||
const col = sel && colOf(sel.c);
|
||||
if (!sel || !col || !keyEditable(state, sel.r, col)) return;
|
||||
const td = cellsAt(sel.r, sel.c)[0];
|
||||
if (!td) return;
|
||||
startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value));
|
||||
const box = td.querySelector("input");
|
||||
if (box && initial !== undefined) box.value = initial;
|
||||
};
|
||||
const editHead = (th: HTMLElement): void => {
|
||||
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
|
||||
const level = Number(th.dataset.level);
|
||||
if (start < 0 || Number.isNaN(level)) return;
|
||||
const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan);
|
||||
const depth = headDepth(state.doc.열, state.doc.층);
|
||||
const tip = el("div", { className: "ui-sheet__headhint", text: st("Head_Hint") });
|
||||
const at = th.getBoundingClientRect();
|
||||
tip.style.left = `${at.left}px`;
|
||||
tip.style.top = `${at.bottom + 2}px`;
|
||||
root.append(tip);
|
||||
startEdit(th, th.textContent ?? "", (value) => {
|
||||
for (const col of span) setHeadLabel(col.머리, level, value, depth);
|
||||
changed();
|
||||
});
|
||||
th.querySelector("input")?.addEventListener("blur", () => tip.remove());
|
||||
};
|
||||
|
||||
// ── 사건 ──────────────────────────────────────────────────────────
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input")) return;
|
||||
const td = target.closest<HTMLElement>("td[data-r]");
|
||||
if (td) select(td.dataset.r!, td.dataset.c!);
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
if (th) select(state.sel?.r ?? KEY_UNIT, th.dataset.col!);
|
||||
root.focus({ preventScroll: true });
|
||||
};
|
||||
const onDblClick = (event: MouseEvent): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input")) return;
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
if (th && state.mode === "master") editHead(th);
|
||||
else if (target.closest("td[data-r]")) edit();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
const typing = (event.target as HTMLElement).tagName === "INPUT";
|
||||
if (typing) {
|
||||
if (event.key === "Enter") {
|
||||
move(1, 0);
|
||||
root.focus({ preventScroll: true });
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLInputElement).blur();
|
||||
move(0, event.shiftKey ? -1 : 1);
|
||||
root.focus({ preventScroll: true });
|
||||
} else if (event.key === "Escape") {
|
||||
render();
|
||||
root.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const arrows: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
if (event.key in arrows) {
|
||||
event.preventDefault();
|
||||
move(...arrows[event.key]);
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
move(0, event.shiftKey ? -1 : 1);
|
||||
} else if (event.key === "Enter" || event.key === "F2") {
|
||||
event.preventDefault();
|
||||
edit();
|
||||
} else if (event.key === "Delete" || event.key === "Backspace") {
|
||||
const col = state.sel && colOf(state.sel.c);
|
||||
if (state.sel && col && keyEditable(state, state.sel.r, col)) {
|
||||
event.preventDefault();
|
||||
write(state.sel.r, col, "");
|
||||
}
|
||||
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
edit(event.key);
|
||||
}
|
||||
};
|
||||
|
||||
// 열 너비 끌기 — 단위 줄 칸 오른쪽 손잡이
|
||||
let drag: { id: string; x: number; width: number; total: number } | null = null;
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-resize]");
|
||||
const col = handle && colOf(handle.dataset.resize!);
|
||||
if (!handle || !col) return;
|
||||
event.preventDefault();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
|
||||
const now = scroll.querySelector<HTMLElement>(`col[data-col="${CSS.escape(col.id)}"]`);
|
||||
drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total };
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (!drag) return;
|
||||
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
||||
for (const c of scroll.querySelectorAll<HTMLElement>(`col[data-col="${CSS.escape(drag.id)}"]`))
|
||||
c.style.width = `${width}px`;
|
||||
for (const table of scroll.querySelectorAll<HTMLElement>("table"))
|
||||
table.style.width = `${drag.total - drag.width + width}px`;
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent): void => {
|
||||
if (!drag) return;
|
||||
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
||||
const id = drag.id;
|
||||
drag = null;
|
||||
setColumnWidth(state.doc, id, width);
|
||||
changed();
|
||||
};
|
||||
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("dblclick", onDblClick);
|
||||
root.addEventListener("keydown", onKey);
|
||||
root.addEventListener("pointerdown", onPointerDown);
|
||||
root.addEventListener("pointermove", onPointerMove);
|
||||
root.addEventListener("pointerup", onPointerUp);
|
||||
render();
|
||||
|
||||
return {
|
||||
getDoc: () => structuredClone(state.doc),
|
||||
setDoc: (doc) => {
|
||||
state.doc = structuredClone(doc);
|
||||
state.sel = null;
|
||||
render();
|
||||
},
|
||||
recalc: () => {
|
||||
render();
|
||||
return structuredClone(state.result);
|
||||
},
|
||||
destroy: () => {
|
||||
root.remove();
|
||||
drag = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_formula.ts
|
||||
* 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함.
|
||||
*
|
||||
* 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN ·
|
||||
* MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수).
|
||||
* `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음).
|
||||
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음.
|
||||
* 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
type Frac,
|
||||
frac,
|
||||
FormulaError,
|
||||
type IntMode,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
ZERO,
|
||||
} from "./ui_template_sheet_frac";
|
||||
|
||||
type Token =
|
||||
| { kind: "num"; text: string }
|
||||
| { kind: "str"; text: string }
|
||||
| { kind: "id"; text: string }
|
||||
| { kind: "ref"; text: string }
|
||||
| { kind: "op"; text: string };
|
||||
|
||||
export type Node =
|
||||
| { type: "num"; value: Frac }
|
||||
| { type: "str"; value: string }
|
||||
| { type: "ref"; col: string; row: string | null }
|
||||
| { type: "var"; name: string }
|
||||
| { type: "unary"; op: string; arg: Node }
|
||||
| { type: "binary"; op: string; left: Node; right: Node }
|
||||
| { type: "call"; name: string; args: Node[] };
|
||||
|
||||
export type Value = Frac | string | boolean;
|
||||
|
||||
/** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */
|
||||
export interface Scope {
|
||||
cell(col: string, row: string | null): Value;
|
||||
column(col: string): Frac[];
|
||||
variable(name: string): Value;
|
||||
}
|
||||
|
||||
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"];
|
||||
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
|
||||
const ROUNDERS: Record<string, IntMode> = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" };
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
|
||||
if (number) {
|
||||
tokens.push({ kind: "num", text: number[0] });
|
||||
i += number[0].length;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
const end = source.indexOf("]", i + 1);
|
||||
if (end < 0) throw new FormulaError("「[」가 닫히지 않음");
|
||||
tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const end = source.indexOf(ch, i + 1);
|
||||
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
|
||||
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
||||
if (identifier) {
|
||||
tokens.push({ kind: "id", text: identifier[0].toUpperCase() });
|
||||
i += identifier[0].length;
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
|
||||
tokens.push({ kind: "op", text: op });
|
||||
i += op.length;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function refNode(text: string): Node {
|
||||
if (!text) throw new FormulaError("빈 참조 []");
|
||||
if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() };
|
||||
const at = text.indexOf("@");
|
||||
if (at < 0) return { type: "ref", col: text, row: null };
|
||||
return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() };
|
||||
}
|
||||
|
||||
/** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */
|
||||
export function parseFormula(source: string): Node {
|
||||
const tokens = tokenize(source.trim().replace(/^=/, ""));
|
||||
let at = 0;
|
||||
const peek = (): Token | undefined => tokens[at];
|
||||
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
|
||||
const expect = (text: string): void => {
|
||||
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
|
||||
at += 1;
|
||||
};
|
||||
|
||||
const comparison = (): Node => {
|
||||
let left = additive();
|
||||
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: additive() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const additive = (): Node => {
|
||||
let left = term();
|
||||
while (isOp("+") || isOp("-")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: term() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const term = (): Node => {
|
||||
let left = unary();
|
||||
while (isOp("*") || isOp("/")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: unary() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const unary = (): Node => {
|
||||
if (isOp("-") || isOp("+")) {
|
||||
const op = tokens[at++].text;
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const primary = (): Node => {
|
||||
const token = peek();
|
||||
if (!token) throw new FormulaError("식이 중간에 끝남");
|
||||
at += 1;
|
||||
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
|
||||
if (token.kind === "str") return { type: "str", value: token.text };
|
||||
if (token.kind === "ref") return refNode(token.text);
|
||||
if (token.kind === "id") {
|
||||
if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`);
|
||||
at += 1;
|
||||
const args: Node[] = [];
|
||||
if (!isOp(")")) {
|
||||
args.push(comparison());
|
||||
while (isOp(",")) {
|
||||
at += 1;
|
||||
args.push(comparison());
|
||||
}
|
||||
}
|
||||
expect(")");
|
||||
return { type: "call", name: token.text, args };
|
||||
}
|
||||
if (token.text === "(") {
|
||||
const inner = comparison();
|
||||
expect(")");
|
||||
return inner;
|
||||
}
|
||||
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
|
||||
};
|
||||
|
||||
if (!tokens.length) throw new FormulaError("빈 식");
|
||||
const tree = comparison();
|
||||
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */
|
||||
export function* refsOf(node: Node): Generator<Node & { type: "ref" | "var" }> {
|
||||
if (node.type === "ref" || node.type === "var") yield node;
|
||||
else if (node.type === "unary") yield* refsOf(node.arg);
|
||||
else if (node.type === "binary") {
|
||||
yield* refsOf(node.left);
|
||||
yield* refsOf(node.right);
|
||||
} else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg);
|
||||
}
|
||||
|
||||
const isFrac = (v: Value): v is Frac => typeof v === "object";
|
||||
|
||||
export function asFrac(value: Value): Frac {
|
||||
if (isFrac(value)) return value;
|
||||
if (typeof value === "boolean") return frac(value ? 1n : 0n);
|
||||
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function truthy(value: Value): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (isFrac(value)) return value.n !== 0n;
|
||||
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function compareValues(a: Value, b: Value): number {
|
||||
if (typeof a === "string" || typeof b === "string") {
|
||||
if (typeof a !== "string" || typeof b !== "string") {
|
||||
throw new FormulaError("글과 수를 견줄 수 없음");
|
||||
}
|
||||
return a === b ? 0 : a < b ? -1 : 1;
|
||||
}
|
||||
return cmp(asFrac(a), asFrac(b));
|
||||
}
|
||||
|
||||
function digitsOf(node: Node | undefined, scope: Scope): number {
|
||||
if (!node) return 0;
|
||||
const value = asFrac(evaluate(node, scope));
|
||||
if (value.d !== 1n) throw new FormulaError("자리 수는 정수");
|
||||
return Number(value.n);
|
||||
}
|
||||
|
||||
export function evaluate(node: Node, scope: Scope): Value {
|
||||
switch (node.type) {
|
||||
case "num":
|
||||
case "str":
|
||||
return node.value;
|
||||
case "ref":
|
||||
return scope.cell(node.col, node.row);
|
||||
case "var":
|
||||
return scope.variable(node.name);
|
||||
case "unary": {
|
||||
const value = asFrac(evaluate(node.arg, scope));
|
||||
return node.op === "-" ? sub(ZERO, value) : value;
|
||||
}
|
||||
case "binary": {
|
||||
const left = evaluate(node.left, scope);
|
||||
const right = evaluate(node.right, scope);
|
||||
if (COMPARISONS.has(node.op)) {
|
||||
const order = compareValues(left, right);
|
||||
return {
|
||||
"=": order === 0,
|
||||
"<>": order !== 0,
|
||||
"<": order < 0,
|
||||
"<=": order <= 0,
|
||||
">": order > 0,
|
||||
">=": order >= 0,
|
||||
}[node.op]!;
|
||||
}
|
||||
const a = asFrac(left);
|
||||
const b = asFrac(right);
|
||||
if (node.op === "+") return add(a, b);
|
||||
if (node.op === "-") return sub(a, b);
|
||||
if (node.op === "*") return mul(a, b);
|
||||
return div(a, b);
|
||||
}
|
||||
case "call": {
|
||||
const { name, args } = node;
|
||||
if (name === "IF") {
|
||||
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
|
||||
// 고른 갈래만 풂 — 안 고른 갈래의 오류가 칸을 막지 않게
|
||||
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
|
||||
}
|
||||
if (name === "SUM") {
|
||||
let total = ZERO;
|
||||
for (const arg of args) {
|
||||
const values =
|
||||
arg.type === "ref" && arg.row === null
|
||||
? scope.column(arg.col)
|
||||
: [asFrac(evaluate(arg, scope))];
|
||||
for (const value of values) total = add(total, value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
if (name === "INT") {
|
||||
if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나");
|
||||
return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor"));
|
||||
}
|
||||
if (name in ROUNDERS) {
|
||||
if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`);
|
||||
return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]);
|
||||
}
|
||||
if (name === "MIN" || name === "MAX") {
|
||||
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
|
||||
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
|
||||
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
|
||||
}
|
||||
throw new FormulaError(`모르는 함수: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_frac.ts
|
||||
* 표 식의 수 — BigInt 분수. 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 의 분수 몫을 되살림.
|
||||
*
|
||||
* ⚠ 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐 — 실무 엑셀 `INT(x*100)/100` 이
|
||||
* 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음. 서버(파이썬 Decimal)와 1원도 안 갈림.
|
||||
* ========================================================================== */
|
||||
|
||||
export class FormulaError extends Error {}
|
||||
|
||||
export interface Frac {
|
||||
n: bigint;
|
||||
d: bigint;
|
||||
}
|
||||
|
||||
/** 정수로 떨구는 법 — floor = 엑셀 INT · trunc = ROUNDDOWN · away = ROUNDUP · round = ROUND. */
|
||||
export type IntMode = "floor" | "trunc" | "away" | "round";
|
||||
|
||||
const DIGITS = 30;
|
||||
const TEN = 10n;
|
||||
|
||||
const abs = (x: bigint): bigint => (x < 0n ? -x : x);
|
||||
|
||||
function gcd(a: bigint, b: bigint): bigint {
|
||||
a = abs(a);
|
||||
b = abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1n;
|
||||
}
|
||||
|
||||
export function frac(n: bigint, d = 1n): Frac {
|
||||
if (d === 0n) throw new FormulaError("0 으로 나눔");
|
||||
if (d < 0n) [n, d] = [-n, -d];
|
||||
const g = gcd(n, d);
|
||||
return { n: n / g, d: d / g };
|
||||
}
|
||||
|
||||
export const ZERO = frac(0n);
|
||||
export const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
|
||||
export const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
|
||||
export const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
|
||||
export function div(a: Frac, b: Frac): Frac {
|
||||
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
|
||||
return frac(a.n * b.d, a.d * b.n);
|
||||
}
|
||||
export function cmp(a: Frac, b: Frac): number {
|
||||
const left = a.n * b.d;
|
||||
const right = b.n * a.d;
|
||||
return left === right ? 0 : left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
/** 십진 글(`-12.5` · `3` · `1e-3` · `1,234`)을 분수로. */
|
||||
export function parseDecimal(text: string): Frac {
|
||||
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.replace(/,/g, "").trim());
|
||||
if (!match || (match[2] === "" && (match[3] ?? "") === "")) {
|
||||
throw new FormulaError(`수로 읽지 못함: ${text}`);
|
||||
}
|
||||
const [, sign, whole, fraction = "", exponent = "0"] = match;
|
||||
let n = BigInt((whole || "0") + fraction);
|
||||
let d = TEN ** BigInt(fraction.length);
|
||||
const e = Number(exponent);
|
||||
if (e > 0) n *= TEN ** BigInt(e);
|
||||
if (e < 0) d *= TEN ** BigInt(-e);
|
||||
return frac(sign === "-" ? -n : n, d);
|
||||
}
|
||||
|
||||
/** 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음. */
|
||||
export function toFrac(value: number | string): Frac {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
|
||||
return parseDecimal(String(value));
|
||||
}
|
||||
return parseDecimal(value);
|
||||
}
|
||||
|
||||
export function toInteger(x: Frac, mode: IntMode): bigint {
|
||||
const q = x.n / x.d; // 0 쪽으로 자름
|
||||
const r = x.n % x.d;
|
||||
if (r === 0n) return q;
|
||||
const negative = x.n < 0n;
|
||||
if (mode === "floor") return negative ? q - 1n : q;
|
||||
if (mode === "trunc") return q;
|
||||
if (mode === "away") return negative ? q - 1n : q + 1n;
|
||||
// 사사오입 — 엑셀 ROUND 와 같이 0 에서 먼 쪽
|
||||
return abs(r) * 2n >= x.d ? (negative ? q - 1n : q + 1n) : q;
|
||||
}
|
||||
|
||||
/** 소수 `digits` 자리에서 떨굼(음수 자리 = 십 · 백 자리). */
|
||||
export function roundAt(x: Frac, digits: number, mode: IntMode): Frac {
|
||||
const places = Math.trunc(digits);
|
||||
const scale = places >= 0 ? frac(TEN ** BigInt(places)) : frac(1n, TEN ** BigInt(-places));
|
||||
return div(frac(toInteger(mul(x, scale), mode)), scale);
|
||||
}
|
||||
|
||||
/** 분수를 십진 글로 — 끝나는 소수는 그대로 · 안 끝나면 30자리에서 사사오입. */
|
||||
export function fracToString(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = abs(scaled)
|
||||
.toString()
|
||||
.padStart(DIGITS + 1, "0");
|
||||
const whole = digits.slice(0, -DIGITS);
|
||||
const fraction = digits.slice(-DIGITS).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_header.ts
|
||||
* 여러 층 머리 짜기 — 열마다 `머리`(층 수만큼 글) → 층마다 칸(colspan · rowspan).
|
||||
*
|
||||
* `null` = 위 칸과 합침(세로) · 모자란 층도 `null` 로 채움(위 칸이 늘어남).
|
||||
* 옆 열과 그 층까지 앞머리가 모두 같고 아래 합침 모양도 같으면 가로로 합침.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetColumn } from "./ui_template_sheet_types";
|
||||
|
||||
export interface HeadCell {
|
||||
level: number;
|
||||
col: number;
|
||||
colspan: number;
|
||||
rowspan: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function headDepth(cols: SheetColumn[], layers?: string[]): number {
|
||||
return Math.max(1, layers?.length ?? 0, ...cols.map((c) => c.머리.length));
|
||||
}
|
||||
|
||||
/** 층마다 칸 목록 — 앞 층부터. */
|
||||
export function layoutHead(cols: SheetColumn[], depth: number): HeadCell[][] {
|
||||
const paths = cols.map((c) =>
|
||||
Array.from({ length: depth }, (_, i) => (i === 0 ? (c.머리[0] ?? "") : (c.머리[i] ?? null))),
|
||||
);
|
||||
const span = (j: number, i: number): number => {
|
||||
let k = i + 1;
|
||||
while (k < depth && paths[j][k] === null) k += 1;
|
||||
return k - i;
|
||||
};
|
||||
const samePrefix = (a: number, b: number, i: number): boolean =>
|
||||
paths[a].slice(0, i + 1).every((label, lv) => label === paths[b][lv]);
|
||||
|
||||
const levels: HeadCell[][] = [];
|
||||
for (let i = 0; i < depth; i += 1) {
|
||||
const cells: HeadCell[] = [];
|
||||
let j = 0;
|
||||
while (j < cols.length) {
|
||||
if (paths[j][i] === null) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const rowspan = span(j, i);
|
||||
let colspan = 1;
|
||||
while (
|
||||
j + colspan < cols.length &&
|
||||
paths[j + colspan][i] !== null &&
|
||||
samePrefix(j, j + colspan, i) &&
|
||||
span(j + colspan, i) === rowspan
|
||||
) {
|
||||
colspan += 1;
|
||||
}
|
||||
cells.push({ level: i, col: j, colspan, rowspan, label: paths[j][i] as string });
|
||||
j += colspan;
|
||||
}
|
||||
levels.push(cells);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_labels.ts
|
||||
* 사람이 읽는 글 — 식의 열 id 를 열 이름(머리 글)으로 · 머리 글이 안 잘리는 최소 열 폭.
|
||||
*
|
||||
* 열 이름 = 맨 위 글(종류) + 맨 아래 글 · 다른 열과 겹치면 가운데 층을 더 붙임(자리 글 「(…마다)」 는 뺌).
|
||||
* 보이기만 바꿈 — 문서의 식은 그대로 id 식(고칠 때도 id 식).
|
||||
* ========================================================================== */
|
||||
|
||||
import { layoutHead } from "./ui_template_sheet_header";
|
||||
import type { SheetColumn } from "./ui_template_sheet_types";
|
||||
|
||||
const PLACEHOLDER = /[((][^))]*마다[))]/;
|
||||
const NAME_PAD = 16;
|
||||
const MAX_MIN_WIDTH = 220;
|
||||
|
||||
export interface ColumnName {
|
||||
/** 맨 위 글(종류) + 아래 글 — 표 전체에서 안 겹침. */
|
||||
full: string;
|
||||
/** 같은 종류 안에서 부를 이름 — 겹치면 `full`. */
|
||||
short: string;
|
||||
top: string;
|
||||
}
|
||||
|
||||
/** 열 id → 열 이름. */
|
||||
export function columnNames(cols: SheetColumn[]): Map<string, ColumnName> {
|
||||
const paths = cols.map((c) => {
|
||||
const path = c.머리.filter((label): label is string => !!label && !PLACEHOLDER.test(label));
|
||||
return path.length ? path : [c.id];
|
||||
});
|
||||
/** 맨 위 + 아래서 `take` 층 — 모자라면 통째. */
|
||||
const nameOf = (path: string[], take: number): string =>
|
||||
(take + 1 >= path.length ? path : [path[0], ...path.slice(-take)]).join(" ");
|
||||
const names = new Map<string, ColumnName>();
|
||||
cols.forEach((col, j) => {
|
||||
const path = paths[j];
|
||||
let full = path.join(" ");
|
||||
for (let take = 1; take + 1 < path.length; take += 1) {
|
||||
const name = nameOf(path, take);
|
||||
if (!paths.some((other, k) => k !== j && nameOf(other, take) === name)) {
|
||||
full = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const leaf = path.length > 1 ? path.slice(1).join(" ") : path[0];
|
||||
const clash = paths.some(
|
||||
(other, k) =>
|
||||
k !== j &&
|
||||
other[0] === path[0] &&
|
||||
(other.length > 1 ? other.slice(1).join(" ") : other[0]) === leaf,
|
||||
);
|
||||
names.set(col.id, { full, short: clash ? full : leaf, top: path[0] });
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 식 → 읽는 글 — `[열id]` → 열 이름 · `[열id@줄]` → 이름@줄 · `[$변수]` → 변수 이름 · `*` `/` → × ÷.
|
||||
* `host` 를 주면 같은 종류 열은 짧은 이름. */
|
||||
export function formulaLabel(
|
||||
formula: string,
|
||||
names: Map<string, ColumnName>,
|
||||
host?: string,
|
||||
): string {
|
||||
const top = host ? names.get(host)?.top : undefined;
|
||||
return formula
|
||||
.replace(/\[([^\]]+)\]/g, (_, ref: string) => {
|
||||
const text = ref.trim();
|
||||
if (text.startsWith("$")) return text.slice(1).replace(/_/g, " ");
|
||||
const [col, row] = text.split("@");
|
||||
const hit = names.get(col.trim());
|
||||
const name = !hit ? col.trim() : top !== undefined && hit.top === top ? hit.short : hit.full;
|
||||
return row ? `${name}@${row.trim()}` : name;
|
||||
})
|
||||
.replace(/\s*\*\s*/g, " × ")
|
||||
.replace(/\s*\/\s*/g, " ÷ ");
|
||||
}
|
||||
|
||||
let ruler: CanvasRenderingContext2D | null = null;
|
||||
|
||||
export function textWidth(text: string, font: string): number {
|
||||
ruler ??= document.createElement("canvas").getContext("2d");
|
||||
if (!ruler) return text.length * 13;
|
||||
ruler.font = font;
|
||||
return ruler.measureText(text).width;
|
||||
}
|
||||
|
||||
/** 열마다 머리 글 · 단위가 안 잘리는 최소 폭(px) — 여러 열을 덮는 칸은 모자란 만큼 나눠 얹음. */
|
||||
export function headMinWidths(
|
||||
cols: SheetColumn[],
|
||||
depth: number,
|
||||
font: string,
|
||||
): Map<string, number> {
|
||||
const mins = cols.map((c) => textWidth(c.단위 ?? "", font) + NAME_PAD);
|
||||
for (const level of layoutHead(cols, depth).reverse()) {
|
||||
for (const cell of level) {
|
||||
const need = textWidth(cell.label, font) + NAME_PAD;
|
||||
const have = mins.slice(cell.col, cell.col + cell.colspan).reduce((a, b) => a + b, 0);
|
||||
if (need <= have) continue;
|
||||
const extra = (need - have) / cell.colspan;
|
||||
for (let k = cell.col; k < cell.col + cell.colspan; k += 1) mins[k] += extra;
|
||||
}
|
||||
}
|
||||
return new Map(cols.map((c, j) => [c.id, Math.min(MAX_MIN_WIDTH, Math.ceil(mins[j]))]));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_ops.ts
|
||||
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
|
||||
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
|
||||
* ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
export type SheetMode = "master" | "project";
|
||||
|
||||
export const ROW_FIXED_ALL = "전구간";
|
||||
const PAGE_ROWS = 50;
|
||||
|
||||
/** 겹치지 않는 새 id — `c1` · `c2` … */
|
||||
function freshId(prefix: string, used: Iterable<string>): string {
|
||||
const taken = new Set(used);
|
||||
let n = 1;
|
||||
while (taken.has(`${prefix}${n}`)) n += 1;
|
||||
return `${prefix}${n}`;
|
||||
}
|
||||
|
||||
/** 보이는 줄 차례 — 「전구간」 줄이 맨 위 · 나머지는 문서 차례. */
|
||||
export function orderedRows(doc: SheetDoc): SheetRow[] {
|
||||
const fixed = doc.줄.filter((r) => r.고정 === ROW_FIXED_ALL);
|
||||
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
|
||||
}
|
||||
|
||||
export interface SheetPage {
|
||||
rows: SheetRow[];
|
||||
/** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */
|
||||
first: number;
|
||||
/** 합계 줄을 붙이나 — 마지막 쪽만. */
|
||||
totals: boolean;
|
||||
}
|
||||
|
||||
/** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */
|
||||
export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] {
|
||||
const rows = orderedRows(doc);
|
||||
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length);
|
||||
const count = Math.max(1, Math.ceil(rows.length / size));
|
||||
return Array.from({ length: count }, (_, p) => ({
|
||||
rows: rows.slice(p * size, (p + 1) * size),
|
||||
first: p * size + 1,
|
||||
totals: p === count - 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
|
||||
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
|
||||
|
||||
/** 본문 칸을 고칠 수 있나 — master 는 전부(계산 칸은 `=` 식으로) · project 는 손 열만(+ 사용자가 더한 줄의 글 열). */
|
||||
export function cellEditable(mode: SheetMode, col: SheetColumn, row: SheetRow): boolean {
|
||||
if (mode === "master") return true;
|
||||
if (isBoundColumn(col) || isCalcColumn(col)) return false;
|
||||
return !!col.손 || (!!row.손 && col.꼴 === "글");
|
||||
}
|
||||
|
||||
export function canDeleteRow(mode: SheetMode, row: SheetRow): boolean {
|
||||
if (mode === "master") return true;
|
||||
return !!row.손 && row.고정 !== ROW_FIXED_ALL;
|
||||
}
|
||||
|
||||
/** `after` 줄 뒤에 빈 줄(없으면 맨 끝) — 새 줄 id. project 에서 더한 줄은 `손`. */
|
||||
export function addRow(doc: SheetDoc, mode: SheetMode, after: string | null): string {
|
||||
const id = freshId(
|
||||
"r",
|
||||
doc.줄.map((r) => r.id),
|
||||
);
|
||||
const row: SheetRow = { id, 값: {}, ...(mode === "project" ? { 손: true } : {}) };
|
||||
const at = doc.줄.findIndex((r) => r.id === after);
|
||||
doc.줄.splice(at < 0 ? doc.줄.length : at + 1, 0, row);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deleteRow(doc: SheetDoc, id: string): void {
|
||||
doc.줄 = doc.줄.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
/** `after` 열 오른쪽에 같은 묶음의 새 열 — 윗머리는 그대로 · 맨 아래 글만 「새 열」. */
|
||||
export function addColumn(doc: SheetDoc, after: string | null, label: string): string {
|
||||
const id = freshId(
|
||||
"c",
|
||||
doc.열.map((c) => c.id),
|
||||
);
|
||||
const at = doc.열.findIndex((c) => c.id === after);
|
||||
const base = at < 0 ? null : doc.열[at];
|
||||
const head = base ? [...base.머리] : [label];
|
||||
if (base) {
|
||||
let last = head.length - 1;
|
||||
while (last > 0 && head[last] === null) last -= 1;
|
||||
head[last] = label;
|
||||
}
|
||||
const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" };
|
||||
doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
|
||||
export function deleteColumn(doc: SheetDoc, id: string): void {
|
||||
doc.열 = doc.열.filter((c) => c.id !== id);
|
||||
for (const row of doc.줄) {
|
||||
delete row.값[id];
|
||||
if (row.식) delete row.식[id];
|
||||
}
|
||||
if (doc.보기?.열너비) delete doc.보기.열너비[id];
|
||||
}
|
||||
|
||||
/** 칸에 친 글 → 저장 값. `=` 로 시작하면 그 칸만의 식(`줄.식`) · 수 열은 수 꼴이면 수 · 빈 글은 칸을 비움. */
|
||||
export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: string): void {
|
||||
const row = doc.줄.find((r) => r.id === rowId);
|
||||
if (!row) return;
|
||||
const trimmed = text.trim();
|
||||
if (row.식) {
|
||||
delete row.식[col.id];
|
||||
if (!Object.keys(row.식).length) delete row.식;
|
||||
}
|
||||
if (trimmed.startsWith("=") && trimmed.slice(1).trim() !== (col.식 ?? "")) {
|
||||
delete row.값[col.id];
|
||||
(row.식 ??= {})[col.id] = trimmed.slice(1).trim();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "" || trimmed.startsWith("=")) {
|
||||
delete row.값[col.id];
|
||||
return;
|
||||
}
|
||||
const plain = trimmed.replace(/,/g, "");
|
||||
const value: SheetCell =
|
||||
col.꼴 !== "글" && /^-?(\d+(\.\d*)?|\.\d+)$/.test(plain) ? Number(plain) : trimmed;
|
||||
// ⚠ 긴 소수는 Number 로 바꾸면 끝자리가 틀어짐 — 보이는 글 그대로 둠(풀이가 십진으로 읽음)
|
||||
row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value;
|
||||
}
|
||||
|
||||
/** 머리 칸 고치기 — 글에 `/` 가 있으면 `level` 층부터 아래로 나눠 넣음(층보다 많으면 끝 층에 이어 붙임) ·
|
||||
* 층보다 적게 쓰면 남은 층은 `null`(위 칸과 합침). `/` 가 없으면 그 층 글만 바꿈. */
|
||||
export function setHeadLabel(head: (string | null)[], level: number, text: string, depth: number) {
|
||||
while (head.length <= level) head.push(null);
|
||||
const parts = text.split("/").map((part) => part.trim());
|
||||
if (parts.length < 2) {
|
||||
head[level] = text.trim();
|
||||
return;
|
||||
}
|
||||
const room = Math.max(1, depth - level);
|
||||
const fit =
|
||||
parts.length > room ? [...parts.slice(0, room - 1), parts.slice(room - 1).join("/")] : parts;
|
||||
while (head.length < depth) head.push(null);
|
||||
for (let k = level; k < depth; k += 1) head[k] = fit[k - level] ?? null;
|
||||
}
|
||||
|
||||
export function setColumnWidth(doc: SheetDoc, id: string, width: number): void {
|
||||
doc.보기 ??= {};
|
||||
(doc.보기.열너비 ??= {})[id] = Math.round(width);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_recalc.ts
|
||||
* 표 문서 한 벌 풀이 — 계산 열 · 한 칸만 다른 식(`줄.식`) · 합계 줄(열마다 다른 식) · 변수.
|
||||
*
|
||||
* 화면(조작 중 즉시)과 서버(`common_util_sheet_recalc_node.ts` — [저장] 때 정본 재계산)가
|
||||
* **이 파일 하나**를 같이 씀(CLAUDE.md 5장 ②). 두 벌로 짜면 끝수가 조용히 갈림.
|
||||
* 순환은 풀면서 잡음 — 풀고 있는 칸을 다시 부르면 그 고리의 칸마다 오류.
|
||||
* 오류 칸은 값 없이 `오류` 에 까닭과 함께 — 나머지 칸은 계속 풂.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
type Frac,
|
||||
FormulaError,
|
||||
fracToString,
|
||||
roundAt,
|
||||
toFrac,
|
||||
ZERO,
|
||||
} from "./ui_template_sheet_frac";
|
||||
import {
|
||||
evaluate,
|
||||
parseFormula,
|
||||
type Node,
|
||||
type Scope,
|
||||
type Value,
|
||||
} from "./ui_template_sheet_formula";
|
||||
import type {
|
||||
SheetColumn,
|
||||
SheetDoc,
|
||||
SheetError,
|
||||
SheetResult,
|
||||
SheetRounding,
|
||||
SheetTotal,
|
||||
} from "./ui_template_sheet_types";
|
||||
|
||||
const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const;
|
||||
|
||||
/** 수 칸에 수가 아닌 글 — 계산은 그 칸을 빼고 하되 알림(엑셀처럼 무시 · 오류 목록에는 이 까닭으로). */
|
||||
export const NOT_A_NUMBER = "수가 아님";
|
||||
export const SKIPPED_PREFIX = "수가 아닌 칸";
|
||||
/** 값은 그대로 두고 알리기만 하는 까닭인가 — 화면이 글을 「#오류」 로 바꾸지 않게. */
|
||||
export const isNotice = (why: string): boolean =>
|
||||
why === NOT_A_NUMBER || why.startsWith(SKIPPED_PREFIX);
|
||||
|
||||
export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글";
|
||||
|
||||
/** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */
|
||||
export function totalFormula(total: SheetTotal, col: SheetColumn): string | null {
|
||||
if (!isNumberColumn(col)) return null;
|
||||
const text = typeof total.식 === "string" ? total.식 : (total.식[col.id] ?? total.식["*"]);
|
||||
if (!text || !text.trim()) return null;
|
||||
return text.trim().toUpperCase() === "SUM" ? `SUM([${col.id}])` : text;
|
||||
}
|
||||
|
||||
/** 칸 값 → 수 · 글. 빈 칸은 null. */
|
||||
function inputValue(raw: unknown): Value | null {
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
if (typeof raw === "number") return toFrac(raw);
|
||||
const text = String(raw).trim();
|
||||
if (text === "") return null;
|
||||
try {
|
||||
return toFrac(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRound(value: Value, rounding: SheetRounding | null | undefined): Value {
|
||||
if (!rounding || typeof value !== "object") return value;
|
||||
return roundAt(value, rounding.자리 ?? 0, ROUND_MODE[rounding.방법] ?? "round");
|
||||
}
|
||||
|
||||
const show = (value: Value): string =>
|
||||
typeof value === "object"
|
||||
? fracToString(value)
|
||||
: typeof value === "boolean"
|
||||
? value
|
||||
? "1"
|
||||
: "0"
|
||||
: value;
|
||||
|
||||
class Failed {
|
||||
constructor(readonly message: string) {}
|
||||
}
|
||||
|
||||
export function recalcSheet(doc: SheetDoc): SheetResult {
|
||||
const cols = new Map(doc.열.map((c) => [c.id, c]));
|
||||
const rows = new Map(doc.줄.map((r) => [r.id, r]));
|
||||
const totals = new Map((doc.합계줄 ?? []).map((t) => [t.id, t]));
|
||||
const parsed = new Map<string, Node | Failed>();
|
||||
const memo = new Map<string, Value | null | Failed>();
|
||||
const visiting = new Set<string>();
|
||||
const result: SheetResult = { 계산: {}, 합계: {}, 오류: [] };
|
||||
|
||||
const parse = (text: string): Node => {
|
||||
let node = parsed.get(text);
|
||||
if (!node) {
|
||||
try {
|
||||
node = parseFormula(text);
|
||||
} catch (error) {
|
||||
node = new Failed(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
parsed.set(text, node);
|
||||
}
|
||||
if (node instanceof Failed) throw new FormulaError(node.message);
|
||||
return node;
|
||||
};
|
||||
|
||||
const formulaOf = (rowId: string, col: SheetColumn): string | null => {
|
||||
const row = rows.get(rowId);
|
||||
if (row) return row.식?.[col.id] ?? col.식 ?? null;
|
||||
return totalFormula(totals.get(rowId)!, col);
|
||||
};
|
||||
|
||||
/** 한 칸 — 식 칸이면 풀어 끝수까지 · 입력 칸이면 값. 빈 칸 null. */
|
||||
const cell = (rowId: string, colId: string): Value | null => {
|
||||
const col = cols.get(colId);
|
||||
if (!col) throw new FormulaError(`없는 열: ${colId}`);
|
||||
if (!rows.has(rowId) && !totals.has(rowId)) throw new FormulaError(`없는 줄: ${rowId}`);
|
||||
const key = `${rowId}\u0000${colId}`;
|
||||
if (memo.has(key)) {
|
||||
const hit = memo.get(key)!;
|
||||
if (hit instanceof Failed) throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
|
||||
return hit;
|
||||
}
|
||||
const formula = formulaOf(rowId, col);
|
||||
if (!formula) {
|
||||
const value = rows.has(rowId) ? inputValue(rows.get(rowId)!.값[colId]) : null;
|
||||
memo.set(key, value);
|
||||
return value;
|
||||
}
|
||||
if (visiting.has(key)) throw new FormulaError("돌고 도는 참조");
|
||||
visiting.add(key);
|
||||
try {
|
||||
const value = applyRound(evaluate(parse(formula), scopeFor(rowId)), col.끝수);
|
||||
memo.set(key, value);
|
||||
return value;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
memo.set(key, new Failed(message));
|
||||
result.오류.push({ 줄: rowId, 열: colId, 까닭: message } satisfies SheetError);
|
||||
throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
|
||||
} finally {
|
||||
visiting.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const scopeFor = (rowId: string): Scope => ({
|
||||
cell: (col, row) => cell(row ?? rowId, col) ?? ZERO,
|
||||
column: (col) => {
|
||||
const out: Frac[] = [];
|
||||
for (const id of rows.keys()) {
|
||||
const value = cell(id, col);
|
||||
if (typeof value === "object" && value !== null) out.push(value);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
variable: (name) => {
|
||||
const raw = doc.변수?.[name];
|
||||
if (raw === undefined) throw new FormulaError(`없는 변수: $${name}`);
|
||||
return inputValue(raw) ?? ZERO;
|
||||
},
|
||||
});
|
||||
|
||||
const solve = (rowId: string, into: Record<string, Record<string, string>>): void => {
|
||||
for (const col of doc.열) {
|
||||
if (!formulaOf(rowId, col)) continue;
|
||||
try {
|
||||
const value = cell(rowId, col.id);
|
||||
(into[rowId] ??= {})[col.id] = value === null ? "" : show(value);
|
||||
} catch {
|
||||
// 까닭은 `오류` 에 이미 적힘
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const id of rows.keys()) solve(id, result.계산);
|
||||
for (const id of totals.keys()) solve(id, result.합계);
|
||||
|
||||
// 수 칸에 든 수 아닌 글 — 그 칸 + 그 열을 더하는 합계 칸에 알림
|
||||
const badCount = new Map<string, number>();
|
||||
for (const row of doc.줄) {
|
||||
for (const col of doc.열) {
|
||||
if (!isNumberColumn(col) || formulaOf(row.id, col)) continue;
|
||||
if (typeof inputValue(row.값[col.id]) !== "string") continue;
|
||||
result.오류.push({ 줄: row.id, 열: col.id, 까닭: NOT_A_NUMBER });
|
||||
badCount.set(col.id, (badCount.get(col.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const total of totals.values()) {
|
||||
for (const col of doc.열) {
|
||||
const n = badCount.get(col.id);
|
||||
const formula = n && totalFormula(total, col);
|
||||
if (!formula || !formula.includes(`[${col.id}`)) continue;
|
||||
const 까닭 = `${SKIPPED_PREFIX} ${n} 개는 빼고 더함`;
|
||||
result.오류.push({ 줄: total.id, 열: col.id, 까닭 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 화면 표시용 — 십진 글에 천 단위 쉼표. */
|
||||
export function groupDigits(text: string): string {
|
||||
const match = /^(-?)(\d+)(\.\d+)?$/.exec(text);
|
||||
if (!match) return text;
|
||||
return `${match[1]}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${match[3] ?? ""}`;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_render.ts
|
||||
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
|
||||
*
|
||||
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { headDepth, layoutHead } from "./ui_template_sheet_header";
|
||||
import { columnNames, formulaLabel, headMinWidths, textWidth } from "./ui_template_sheet_labels";
|
||||
import {
|
||||
cellEditable,
|
||||
isBoundColumn,
|
||||
isCalcColumn,
|
||||
orderedRows,
|
||||
pagePlan,
|
||||
type SheetMode,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
|
||||
const GUTTER_MIN = 64;
|
||||
const GUTTER_MAX = 140;
|
||||
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
|
||||
|
||||
export interface RenderState {
|
||||
doc: SheetDoc;
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
}
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
Math.max(
|
||||
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"],
|
||||
mins.get(col.id) ?? 0,
|
||||
);
|
||||
|
||||
/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
|
||||
function headFont(): string {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
}
|
||||
|
||||
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
|
||||
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
|
||||
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
|
||||
return [
|
||||
...special,
|
||||
...orderedRows(doc).map((r) => `d:${r.id}`),
|
||||
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */
|
||||
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
|
||||
if (key.startsWith("s:")) return state.mode === "master";
|
||||
if (key.startsWith("t:")) return false;
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
return !!row && cellEditable(state.mode, col, row);
|
||||
}
|
||||
|
||||
const shown = (text: string | undefined): string =>
|
||||
!text || text === "0" ? "" : groupDigits(text);
|
||||
|
||||
/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */
|
||||
function kindOf(col: SheetColumn): { cls: string; label: string } | null {
|
||||
if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") };
|
||||
if (isBoundColumn(col)) {
|
||||
const label = st("Kind_Bound");
|
||||
return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label };
|
||||
}
|
||||
if (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderSheet(state: RenderState): HTMLElement {
|
||||
const { doc, mode } = state;
|
||||
const cols = doc.열;
|
||||
const depth = headDepth(cols, doc.층);
|
||||
const head = layoutHead(cols, depth);
|
||||
const names = columnNames(cols);
|
||||
const font = headFont();
|
||||
const mins = headMinWidths(cols, depth, font);
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 합계 이름이 안 잘리게
|
||||
const GUTTER = Math.min(
|
||||
GUTTER_MAX,
|
||||
Math.max(
|
||||
GUTTER_MIN,
|
||||
...[
|
||||
...(doc.층 ?? []),
|
||||
st("Row_Unit"),
|
||||
st("Row_Formula"),
|
||||
st("Row_Desc"),
|
||||
st("Row_Unit_Price"),
|
||||
...(doc.합계줄 ?? []).map((t) => t.이름),
|
||||
].map((label) => Math.ceil(textWidth(label, font)) + 16),
|
||||
),
|
||||
);
|
||||
const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭]));
|
||||
for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭);
|
||||
const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length);
|
||||
const lefts: number[] = [];
|
||||
let left = GUTTER;
|
||||
for (const col of cols) {
|
||||
lefts.push(left);
|
||||
left += columnWidth(doc, col, mins);
|
||||
}
|
||||
|
||||
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
|
||||
if (from + span > frozen) return;
|
||||
cell.classList.add("is-frozen");
|
||||
cell.style.left = `${lefts[from]}px`;
|
||||
};
|
||||
const gutter = (tag: "th" | "td", text: string, cls = ""): HTMLElement =>
|
||||
el(tag, { className: `ui-sheet__gutter ${cls}`.trim(), text });
|
||||
|
||||
const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => {
|
||||
const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } });
|
||||
const kind = kindOf(col);
|
||||
if (kind) td.classList.add(kind.cls);
|
||||
if (col.꼴 !== "글") td.classList.add("is-num");
|
||||
td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked");
|
||||
const why = errors.get(`${key}|${col.id}`);
|
||||
if (why) {
|
||||
td.classList.add("is-error");
|
||||
if (!isNotice(why)) td.textContent = st("Error");
|
||||
td.title = why;
|
||||
} else if (text) td.title = text;
|
||||
if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected");
|
||||
freeze(td, j);
|
||||
return td;
|
||||
};
|
||||
|
||||
const dataRow = (row: SheetRow, n: number): HTMLElement => {
|
||||
const key = `d:${row.id}`;
|
||||
const tr = el("tr", { children: [gutter("th", String(n))] });
|
||||
if (row.고정) tr.classList.add("is-fixed");
|
||||
cols.forEach((col, j) => {
|
||||
const formula = row.식?.[col.id] ?? col.식;
|
||||
const raw = row.값[col.id];
|
||||
const text = formula
|
||||
? shown(state.result.계산[row.id]?.[col.id])
|
||||
: raw === null || raw === undefined
|
||||
? ""
|
||||
: col.꼴 !== "글" && typeof raw === "number"
|
||||
? groupDigits(String(raw))
|
||||
: String(raw);
|
||||
const td = bodyCell(key, col, j, text);
|
||||
if (row.식?.[col.id]) td.classList.add("is-override");
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
|
||||
const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => {
|
||||
const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[
|
||||
key
|
||||
] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price";
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const text =
|
||||
key === "s:formula"
|
||||
? formulaLabel(col.식 ?? "", names, col.id)
|
||||
: key === "s:desc"
|
||||
? ""
|
||||
: (col.일위대가 ?? "");
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (key === "s:formula" && col.식) {
|
||||
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
|
||||
td.textContent = "";
|
||||
td.append(el("div", { className: "ui-sheet__clamp", text }));
|
||||
td.title = `${formulaLabel(col.식, names)}\n${col.식}`;
|
||||
}
|
||||
const kind = kindOf(col);
|
||||
if (key === "s:desc") {
|
||||
// 두세 줄로 자름 — 전문은 마우스를 올리면(title)
|
||||
const desc = col.설명 ?? "";
|
||||
td.title = desc;
|
||||
td.append(
|
||||
el("div", {
|
||||
className: "ui-sheet__clamp",
|
||||
children: [
|
||||
...(kind
|
||||
? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })]
|
||||
: []),
|
||||
desc,
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
|
||||
const thead = (): HTMLElement => {
|
||||
const rows = head.map((cells, level) => {
|
||||
const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] });
|
||||
for (const cell of cells) {
|
||||
const th = el("th", {
|
||||
text: cell.label,
|
||||
attrs: {
|
||||
title: cell.label,
|
||||
colspan: String(cell.colspan),
|
||||
rowspan: String(cell.rowspan),
|
||||
"data-level": String(cell.level),
|
||||
"data-col": cols[cell.col].id,
|
||||
},
|
||||
});
|
||||
if (mode === "master") th.classList.add("is-editable");
|
||||
freeze(th, cell.col, cell.colspan);
|
||||
tr.append(th);
|
||||
}
|
||||
return tr;
|
||||
});
|
||||
const unit = el("tr", {
|
||||
className: "ui-sheet__unit",
|
||||
children: [gutter("th", st("Row_Unit"))],
|
||||
});
|
||||
cols.forEach((col, j) => {
|
||||
const th = bodyCell(KEY_UNIT, col, j, col.단위 ?? "");
|
||||
th.classList.remove("is-num");
|
||||
th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } }));
|
||||
unit.append(th);
|
||||
});
|
||||
return el("thead", { children: [...rows, unit] });
|
||||
};
|
||||
|
||||
const totalRows = (): HTMLElement[] =>
|
||||
(doc.합계줄 ?? []).map((total) => {
|
||||
const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] });
|
||||
cols.forEach((col, j) => {
|
||||
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
|
||||
const formula = totalFormula(total, col);
|
||||
if (formula)
|
||||
td.title = td.classList.contains("is-error")
|
||||
? `${td.title}
|
||||
${formulaLabel(formula, names)}`
|
||||
: formulaLabel(formula, names);
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
|
||||
const colgroup = el("colgroup", {
|
||||
children: [
|
||||
el("col", { attrs: { style: `width:${GUTTER}px` } }),
|
||||
...cols.map((col) =>
|
||||
el("col", {
|
||||
attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id },
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const plan = pagePlan(doc, mode);
|
||||
const pageCount = plan.length;
|
||||
const pages: HTMLElement[] = [];
|
||||
for (const [p, page] of plan.entries()) {
|
||||
const body = el("tbody", {
|
||||
children: [
|
||||
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
|
||||
...page.rows.map((row, i) => dataRow(row, page.first + i)),
|
||||
...(page.totals ? totalRows() : []),
|
||||
],
|
||||
});
|
||||
const table = el("table", {
|
||||
className: "ui-sheet__table",
|
||||
attrs: { style: `width:${left}px` },
|
||||
children: [colgroup.cloneNode(true) as HTMLElement, thead(), body],
|
||||
});
|
||||
if (pageCount > 1) {
|
||||
table.prepend(
|
||||
el("caption", {
|
||||
text: `${p + 1} / ${pageCount} ${st("Page")}`,
|
||||
className: "ui-sheet__page",
|
||||
}),
|
||||
);
|
||||
}
|
||||
pages.push(table);
|
||||
}
|
||||
return el("div", { className: "ui-sheet__pages", children: pages });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_text.ts
|
||||
* 표 부품 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Row_Add: ["줄 더하기", "Add row"],
|
||||
Row_Delete: ["줄 지우기", "Delete row"],
|
||||
Col_Add: ["열 더하기", "Add column"],
|
||||
Col_Delete: ["열 지우기", "Delete column"],
|
||||
Col_Hand: ["손 입력 열", "Manual column"],
|
||||
Col_New: ["새 열", "New column"],
|
||||
Row_Unit: ["단위", "Unit"],
|
||||
Row_Formula: ["식", "Formula"],
|
||||
Row_Desc: ["들어갈 것", "Source"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Kind_Bound: ["설계값", "Design"],
|
||||
Kind_Calc: ["계산", "Calc"],
|
||||
Kind_Hand: ["손 입력", "Manual"],
|
||||
Kind_Spread: ["값마다 열", "Per value"],
|
||||
Page: ["쪽", "page"],
|
||||
Head_Hint: [
|
||||
"/ 로 이으면 층으로 나뉨 (예 관공/Φ800/관매설) · 적게 쓰면 남은 층은 위 칸과 합침",
|
||||
"Use / to split into layers (e.g. A/B/C) · fewer parts merge the rest with the cell above",
|
||||
],
|
||||
Error: ["#오류", "#ERR"],
|
||||
Hint: [
|
||||
"칸을 누르고 바로 치거나 Enter · F2 로 고침 · = 로 시작하면 그 칸만 식 · 화살표 · Tab 으로 옮김 · Delete 로 비움",
|
||||
"Type or press Enter / F2 to edit · start with = for a cell formula · arrows / Tab to move · Delete to clear",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function st(key: keyof typeof TEXT): string {
|
||||
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_types.ts
|
||||
* 표 문서(마스터 템플릿 표 양식) 모양 — `tmp/M02_분석/3_표양식.md` 4장 + 창끼리 계약 「표 문서」.
|
||||
*
|
||||
* 줄 `값` 에는 입력만 둠 — 계산 열 · 합계 줄 값은 저장하지 않고 열 때마다 다시 풂.
|
||||
* 식은 열 id · 줄 id 로 참조 — 줄 · 열을 끼우거나 지워도 식이 안 깨짐.
|
||||
* ========================================================================== */
|
||||
|
||||
export type SheetCell = string | number | null;
|
||||
|
||||
/** 끝수 — 방법 이름은 M01 `master_formula` 와 같음(반올림 · 올림 · 버림). */
|
||||
export interface SheetRounding {
|
||||
자리: number;
|
||||
방법: "반올림" | "올림" | "버림";
|
||||
}
|
||||
|
||||
/** 설계 출처 — sub4 가 채움. 표 부품은 있으면 project 에서 그 열을 잠금. */
|
||||
export interface SheetBinding {
|
||||
종류: string;
|
||||
펼침?: string[];
|
||||
값?: string;
|
||||
}
|
||||
|
||||
export interface SheetColumn {
|
||||
id: string;
|
||||
/** 층 수만큼 · `null` = 위 칸과 합침(세로) · 옆 열과 앞머리가 같으면 가로로 합침. */
|
||||
머리: (string | null)[];
|
||||
단위?: string | null;
|
||||
꼴?: "수" | "글";
|
||||
/** 줄마다 같은 식 — 있으면 계산 칸(잠김). */
|
||||
식?: string;
|
||||
끝수?: SheetRounding | null;
|
||||
/** 마스터에서 보일 「들어갈 것」 글. */
|
||||
설명?: string;
|
||||
바인딩?: SheetBinding | null;
|
||||
/** 일위대가 조합 키. */
|
||||
일위대가?: string | null;
|
||||
/** 마스터 한 칸 → 프로젝트에서 설계 값마다 열. */
|
||||
펼침?: boolean;
|
||||
/** 사용자 입력 열. */
|
||||
손?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetRow {
|
||||
id: string;
|
||||
값: Record<string, SheetCell>;
|
||||
/** 이 줄에서만 다른 식 — 열 식을 이김. */
|
||||
식?: Record<string, string>;
|
||||
/** `전구간` = 맨 위 손 입력 줄. */
|
||||
고정?: string;
|
||||
/** 사용자가 더한 줄 — project 에서 측점을 고치고 지울 수 있음. */
|
||||
손?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetTotal {
|
||||
id: string;
|
||||
이름: string;
|
||||
/** `"SUM"` = 수 열마다 열 합 · 묶음 = 열마다 다른 식(`"*"` = 나머지 열). */
|
||||
식: string | Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SheetView {
|
||||
열너비?: Record<string, number>;
|
||||
틀고정?: { 열?: number };
|
||||
}
|
||||
|
||||
export interface SheetDoc {
|
||||
양식: string;
|
||||
종류: "표";
|
||||
판: number;
|
||||
층?: string[];
|
||||
변수?: Record<string, string | number>;
|
||||
/** 한 쪽 줄 수 — 쪽마다 머리 반복 · 합계는 마지막 쪽. */
|
||||
쪽줄?: number;
|
||||
열: SheetColumn[];
|
||||
줄: SheetRow[];
|
||||
합계줄?: SheetTotal[];
|
||||
보기?: SheetView;
|
||||
}
|
||||
|
||||
export interface SheetError {
|
||||
줄: string;
|
||||
열: string;
|
||||
까닭: string;
|
||||
}
|
||||
|
||||
/** 재계산 결과 — 값은 십진 문자열(끝수 뒤). 화면과 서버 Node 가 같은 모양을 냄. */
|
||||
export interface SheetResult {
|
||||
계산: Record<string, Record<string, string>>;
|
||||
합계: Record<string, Record<string, string>>;
|
||||
오류: SheetError[];
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { ui_locales_b1 } from "./ui_template_locale_b1";
|
||||
import { ui_locales_b2 } from "./ui_template_locale_b2";
|
||||
import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
|
||||
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
|
||||
export const LANGUAGES = ["ko", "en"] as const;
|
||||
@@ -60,6 +61,7 @@ export const ui_locales = {
|
||||
...ui_locales_b2,
|
||||
...ui_locales_b3,
|
||||
...ui_locales_m1,
|
||||
...ui_locales_m2,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_m2.ts
|
||||
* M02 마스터 템플릿 화면 사전 — 신규 문구는 최하단에 추가.
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_m2 = {
|
||||
B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"],
|
||||
M02_Title: ["마스터 템플릿", "Master Templates"],
|
||||
M02_KindTable: ["표 양식", "Table templates"],
|
||||
M02_KindDrawing: ["도면 양식", "Drawing templates"],
|
||||
M02_NoTemplates: ["양식 없음", "None"],
|
||||
M02_New: ["새로", "New"],
|
||||
M02_Copy: ["본떠 만들기", "Copy as new"],
|
||||
M02_Delete: ["지우기", "Delete"],
|
||||
M02_NewTitle: ["새 양식", "New template"],
|
||||
M02_CopyTitle: ["본떠 만들기", "Copy as new"],
|
||||
M02_Kind: ["종류", "Kind"],
|
||||
M02_Name: ["이름", "Name"],
|
||||
M02_Create: ["만들기", "Create"],
|
||||
M02_Close: ["닫기", "Close"],
|
||||
M02_NameNeeded: ["이름을 적을 것", "Enter a name"],
|
||||
M02_DeleteConfirm: ["「{value}」 양식을 지울까?", "Delete template “{value}”?"],
|
||||
M02_Deleted: ["지웠음", "Deleted"],
|
||||
M02_Created: ["만들었음", "Created"],
|
||||
M02_Saved: ["저장했음", "Saved"],
|
||||
M02_Save: ["저장", "Save"],
|
||||
M02_Reload: ["다시 열기", "Reopen"],
|
||||
M02_PickTemplate: ["왼쪽에서 양식을 고를 것", "Pick a template on the left"],
|
||||
|
||||
M02_ReadOnlyLayer: ["이 층은 여기서 고치지 못함", "This layer is read-only here"],
|
||||
M02_LoadFailed: ["양식을 못 읽음 — {value}", "Failed to load — {value}"],
|
||||
M02_SaveFailed: ["저장 못 함 — {value}", "Save failed — {value}"],
|
||||
M02_Stale: [
|
||||
"그 사이 양식이 바뀜 — 다시 열어 새 판으로 고칠 것",
|
||||
"The template changed meanwhile — reopen to edit the latest",
|
||||
],
|
||||
M02_NoSheet: ["표 편집 부품이 아직 없음", "Table editor not available yet"],
|
||||
M02_NoDrawing: ["도면 편집 부품이 아직 없음", "Drawing editor not available yet"],
|
||||
M02_NameExists: ["이미 있는 이름", "That name already exists"],
|
||||
M02_BadName: [
|
||||
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
|
||||
'Invalid name — no / \\ : * ? " < > | or .. or leading dot',
|
||||
],
|
||||
M02_DiscardConfirm: [
|
||||
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
"Unsaved changes — discard and continue?",
|
||||
],
|
||||
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
|
||||
} as const;
|
||||
@@ -0,0 +1,35 @@
|
||||
.ui-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ui-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: min(720px, 92vw);
|
||||
max-height: 80vh;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface-raised);
|
||||
font-size: var(--text-body-sm);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-modal__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* ui_template_modal.ts
|
||||
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
|
||||
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_modal.css";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
|
||||
export interface ModalOptions {
|
||||
title: string;
|
||||
closeLabel: string;
|
||||
mount: (body: HTMLElement, close: () => void) => void;
|
||||
dialogClass?: string;
|
||||
backdropClass?: string;
|
||||
}
|
||||
|
||||
export function openModal(opts: ModalOptions): { close: () => void } {
|
||||
const body = el("div", { className: "ui-modal__body" });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: `ui-modal ${opts.dialogClass ?? ""}`.trim(),
|
||||
attrs: { role: "dialog", "aria-label": opts.title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-modal__top",
|
||||
children: [
|
||||
el("h3", { text: opts.title }),
|
||||
createButton({ label: opts.closeLabel, variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", {
|
||||
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
|
||||
children: [dialog],
|
||||
});
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
opts.mount(body, close);
|
||||
document.body.append(backdrop);
|
||||
dialog.tabIndex = -1;
|
||||
dialog.focus();
|
||||
return { close };
|
||||
}
|
||||
Reference in New Issue
Block a user