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

This commit is contained in:
2026-09-25 22:30:23 +09:00
72 changed files with 7267 additions and 442 deletions
+22 -2
View File
@@ -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();
+9
View File
@@ -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");
+5
View File
@@ -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
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
+3 -4
View File
@@ -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:
+1 -1
View File
@@ -137,7 +137,7 @@ class FrameTemplateSaveRequest(BaseModel):
class FrameTemplateSaveResponse(BaseModel):
"""회사 도각 저장 결과."""
"""프로젝트 도각 저장 결과."""
status: str = "success"
project_id: str
+33 -197
View File
@@ -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;
+16
View File
@@ -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) {
+23 -1
View File
@@ -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);
}
+576
View File
@@ -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)
+343
View File
@@ -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
+173
View File
@@ -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())
+40
View File
@@ -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 }));
+3
View File
@@ -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,
];
/* -----------------------------------------------------------------------------
+14 -2
View File
@@ -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,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 } }
}
+2 -4
View File
@@ -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(
+106
View File
@@ -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"]
+65
View File
@@ -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
+212
View File
@@ -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))
+114
View File
@@ -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],
]
+215
View File
@@ -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)
+104
View File
@@ -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;
}
+267
View File
@@ -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);
},
};
}
@@ -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();
+208
View File
@@ -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;
}
+333
View File
@@ -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}`);
}
}
}
+106
View File
@@ -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]))]));
}
+153
View File
@@ -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[];
}
+2
View File
@@ -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;
+49
View File
@@ -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;
+35
View File
@@ -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);
}
+46
View File
@@ -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 };
}