Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 — Router_Drawing 은 sub2 것 · Router_Layers 는 sub4 것
This commit is contained in:
@@ -175,7 +175,7 @@ export interface FrameTemplateResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
drawing: CadDrawing;
|
||||
/** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */
|
||||
/** 프로젝트 도각이 처음 복사한 도각과 다르면 true. */
|
||||
customized: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */
|
||||
fields?: Record<string, string>;
|
||||
@@ -237,7 +237,7 @@ export async function exportDrawing(
|
||||
};
|
||||
}
|
||||
|
||||
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
|
||||
/** 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다. */
|
||||
export function resetFrameTemplate(projectId: string): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다.
|
||||
|
||||
resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
|
||||
resources/master_template/drawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
|
||||
도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어
|
||||
(b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 —
|
||||
템플릿 쪽을 확대해 콘텐츠를 감싼다.
|
||||
|
||||
A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란,
|
||||
내부 작도 영역 (42, 47) ~ (812, 567).
|
||||
내부 작도 영역 (42, 47) ~ (812, 567). 작도 영역은 양식 칸 `drawing_area` 가 정본이고
|
||||
칸이 없는 양식(옛 회사 도각)은 이 값으로 떨어진다.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
@@ -26,8 +27,19 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
FRAME_LAYER_ID,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
|
||||
from M02_MasterTemplete.M02_Template_Layers import (
|
||||
delete_template,
|
||||
initial_dir,
|
||||
project_dir,
|
||||
reset_project,
|
||||
system_dir,
|
||||
template_path,
|
||||
version_of,
|
||||
write_template,
|
||||
)
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
|
||||
# 도면 양식은 M02 양식 층(PLAN 10-5)의 `drawing` 종류다.
|
||||
_KIND = "drawing"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,29 +49,27 @@ _SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector")
|
||||
|
||||
A1_TEMPLATE = "00_template_A1"
|
||||
COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표
|
||||
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
|
||||
# A1 내부 작도 영역(템플릿 좌표) — 양식에 `drawing_area` 칸이 없을 때만 쓴다.
|
||||
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
|
||||
# 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를
|
||||
# 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자).
|
||||
_CONTENT_MARGIN = 0.02
|
||||
|
||||
# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각
|
||||
# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다
|
||||
# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능").
|
||||
COMPANY_TEMPLATE_SUBDIR = "templates"
|
||||
|
||||
# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
|
||||
# 읽는 차례 = 프로젝트 작업본(`{프로젝트}/templates/drawing/`) → 없으면 시스템 양식
|
||||
# (`resources/master_template/drawing/`, 읽기 전용). 설계자가 고친 도각은 작업본에 쌓인다.
|
||||
#
|
||||
# 이 요청이 읽을 프로젝트 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
|
||||
# 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다.
|
||||
# `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다.
|
||||
_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None)
|
||||
_project_root: ContextVar[Path | None] = ContextVar("b07_project_template_root", default=None)
|
||||
|
||||
|
||||
def use_company_templates(company_dir: Path | None) -> None:
|
||||
"""이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각."""
|
||||
_company_dir.set(company_dir)
|
||||
def use_project_templates(project_root: Path | None) -> None:
|
||||
"""이 요청이 읽을 프로젝트 폴더를 정한다. None이면 시스템 양식."""
|
||||
_project_root.set(project_root)
|
||||
|
||||
|
||||
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
|
||||
# 이 요청이 도각 표제란에 채울 값. 프로젝트 폴더와 같은 이유로 문맥 변수다 —
|
||||
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
|
||||
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
|
||||
|
||||
@@ -78,9 +88,14 @@ def add_title_fields(extra: dict[str, str]) -> None:
|
||||
_title_fields.set({**_title_fields.get(), **(extra or {})})
|
||||
|
||||
|
||||
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
|
||||
"""회사 도각 파일 경로(없을 수도 있다)."""
|
||||
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
|
||||
def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path:
|
||||
"""프로젝트 작업본 도각 파일 경로(없을 수도 있다)."""
|
||||
return template_path(project_dir(project_root), _KIND, name)
|
||||
|
||||
|
||||
def system_template_path(name: str = A1_TEMPLATE) -> Path:
|
||||
"""시스템 도각 파일 경로."""
|
||||
return template_path(system_dir(), _KIND, name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
@@ -90,18 +105,35 @@ def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _load_template(name: str) -> dict[str, Any] | None:
|
||||
"""회사 도각이 있으면 그것, 없으면 프로그램 기본 도각."""
|
||||
company_dir = _company_dir.get()
|
||||
if company_dir is not None:
|
||||
override = company_template_path(company_dir, name)
|
||||
"""프로젝트 작업본이 있으면 그것, 없으면 시스템 양식(옛 프로젝트)."""
|
||||
project_root = _project_root.get()
|
||||
if project_root is not None:
|
||||
override = project_template_path(project_root, name)
|
||||
if override.is_file():
|
||||
return _read_template(str(override), override.stat().st_mtime_ns)
|
||||
path = _TEMPLATE_DIR / f"{name}.json"
|
||||
path = system_template_path(name)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return _read_template(str(path), path.stat().st_mtime_ns)
|
||||
|
||||
|
||||
def drawing_area(name: str = A1_TEMPLATE) -> tuple[float, float, float, float]:
|
||||
"""양식의 내부 작도 영역 (x0, y0, x1, y1) — 콘텐츠가 이 영역 중앙에 놓인다.
|
||||
|
||||
양식 칸 `drawing_area` 를 읽는다. 없거나 어긋나면 A1 기본값(`_A1_INNER`).
|
||||
"""
|
||||
area = (_load_template(name) or {}).get("drawing_area")
|
||||
if (
|
||||
isinstance(area, list)
|
||||
and len(area) == 4
|
||||
and all(isinstance(value, (int, float)) for value in area)
|
||||
and area[0] < area[2]
|
||||
and area[1] < area[3]
|
||||
):
|
||||
return (float(area[0]), float(area[1]), float(area[2]), float(area[3]))
|
||||
return _A1_INNER
|
||||
|
||||
|
||||
def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]:
|
||||
"""도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다."""
|
||||
template = _load_template(name)
|
||||
@@ -157,33 +189,52 @@ def validate_template_entities(entities: list[dict[str, Any]]) -> None:
|
||||
_check(entity, f"#{index}")
|
||||
|
||||
|
||||
def clear_company_template(company_dir: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""회사 도각을 지워 프로그램 기본 도각으로 되돌린다. 지울 것이 없으면 False."""
|
||||
path = company_template_path(company_dir, name)
|
||||
if not path.is_file():
|
||||
def is_project_template_customized(project_root: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""작업본이 처음 복사한 도각(`_initial/`, 없으면 시스템)과 다른가."""
|
||||
current = version_of(project_template_path(project_root, name))
|
||||
if current is None:
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
initial = version_of(template_path(initial_dir(project_root), _KIND, name))
|
||||
return current != (initial or version_of(system_template_path(name)))
|
||||
|
||||
|
||||
def save_company_template(
|
||||
company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
|
||||
def reset_project_template(project_root: Path, name: str = A1_TEMPLATE) -> bool:
|
||||
"""[기본 도각으로] — 처음 복사한 도각(`_initial/`)을 작업본에 덮어쓴다.
|
||||
|
||||
`_initial/` 이 없는 옛 프로젝트는 작업본을 지워 시스템 양식으로 떨어뜨린다.
|
||||
되돌릴 것이 없으면 False.
|
||||
"""
|
||||
if reset_project(project_root, kind=_KIND, name=name):
|
||||
return True
|
||||
return delete_template(project_dir(project_root), _KIND, name)
|
||||
|
||||
|
||||
def save_project_template(
|
||||
project_root: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
|
||||
) -> Path:
|
||||
"""편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다."""
|
||||
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다."""
|
||||
validate_template_entities(entities)
|
||||
path = company_template_path(company_dir, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 작도 영역은 CAD 가 모르는 칸이라 편집본에 없다 — 고치기 전 양식의 값을 이어 받는다.
|
||||
area = (_load_template_at(project_root, name) or {}).get("drawing_area")
|
||||
document = {
|
||||
"format": DRAWING_FORMAT,
|
||||
"source": "B07 도각 편집 화면",
|
||||
**({"drawing_area": area} if area else {}),
|
||||
# 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다.
|
||||
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
|
||||
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
|
||||
}
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return path
|
||||
write_template(project_dir(project_root), _KIND, name, document)
|
||||
return project_template_path(project_root, name)
|
||||
|
||||
|
||||
def _load_template_at(project_root: Path, name: str) -> dict[str, Any] | None:
|
||||
"""요청 문맥과 상관없이 이 프로젝트가 읽는 도각."""
|
||||
token = _project_root.set(project_root)
|
||||
try:
|
||||
return _load_template(name)
|
||||
finally:
|
||||
_project_root.reset(token)
|
||||
|
||||
|
||||
def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
|
||||
@@ -293,15 +344,15 @@ def usable_bbox() -> tuple[float, float, float, float]:
|
||||
여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 —
|
||||
내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다.
|
||||
"""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
ix0, iy0, ix1, iy1 = drawing_area()
|
||||
width, height = usable_area()
|
||||
cx, cy = (ix0 + ix1) / 2.0, (iy0 + iy1) / 2.0
|
||||
return (cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0)
|
||||
|
||||
|
||||
def usable_area() -> tuple[float, float]:
|
||||
"""A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
def usable_area(name: str = A1_TEMPLATE) -> tuple[float, float]:
|
||||
"""양식 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
|
||||
ix0, iy0, ix1, iy1 = drawing_area(name)
|
||||
return (
|
||||
(ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN),
|
||||
(iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN),
|
||||
@@ -444,8 +495,8 @@ def frame_entities(
|
||||
content_w = max(max_x - min_x, 1e-6)
|
||||
content_h = max(max_y - min_y, 1e-6)
|
||||
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
usable_w, usable_h = usable_area()
|
||||
ix0, iy0, ix1, iy1 = drawing_area(template_name)
|
||||
usable_w, usable_h = usable_area(template_name)
|
||||
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
|
||||
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
|
||||
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
|
||||
|
||||
@@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
extract_quantity_table,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
use_company_templates,
|
||||
use_project_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
@@ -292,9 +292,8 @@ async def get_design_drawing(
|
||||
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
|
||||
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
|
||||
use_company_templates(project_root.parent.parent)
|
||||
# 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트).
|
||||
use_project_templates(project_root)
|
||||
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
||||
use_title_fields(await title_block_fields(project_id))
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
|
||||
@@ -18,12 +18,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
clear_company_template,
|
||||
company_template_path,
|
||||
frame_document,
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
use_company_templates,
|
||||
is_project_template_customized,
|
||||
reset_project_template,
|
||||
save_project_template,
|
||||
use_project_templates,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
@@ -42,28 +42,27 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
async def _company_dir(project_id: UUID) -> Path:
|
||||
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`."""
|
||||
async def _project_root(project_id: UUID) -> Path:
|
||||
"""프로젝트 저장 폴더 — `storage/{회사}/{사용자}/{프로젝트}`. 도각 작업본이 여기 산다."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
return root.parent.parent
|
||||
return Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
|
||||
|
||||
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
|
||||
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
|
||||
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
|
||||
"""도각 편집 화면이 실을 도각 한 장. 프로젝트 작업본이 있으면 그것, 없으면 시스템 양식."""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
use_company_templates(company_dir)
|
||||
project_root = await _project_root(project_id)
|
||||
use_project_templates(project_root)
|
||||
# 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는
|
||||
# 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다).
|
||||
fields = await title_block_fields(project_id)
|
||||
return FrameTemplateResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_template_document(),
|
||||
customized=company_template_path(company_dir).is_file(),
|
||||
customized=is_project_template_customized(project_root),
|
||||
fields=fields,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -123,7 +122,7 @@ async def import_frame_template(
|
||||
) -> FrameTemplateImportResponse | JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
|
||||
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
|
||||
"""
|
||||
try:
|
||||
data = await file.read()
|
||||
@@ -150,7 +149,7 @@ async def import_frame_template(
|
||||
async def put_frame_template(
|
||||
project_id: UUID, request: FrameTemplateSaveRequest
|
||||
) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
||||
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 그대로 둔다.
|
||||
|
||||
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
||||
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
||||
@@ -159,8 +158,8 @@ async def put_frame_template(
|
||||
entities = request.drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
raise ValueError("도각 엔티티가 없습니다.")
|
||||
company_dir = await _company_dir(project_id)
|
||||
await asyncio.to_thread(save_company_template, company_dir, entities)
|
||||
project_root = await _project_root(project_id)
|
||||
await asyncio.to_thread(save_project_template, project_root, entities)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id))
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
@@ -174,15 +173,18 @@ async def put_frame_template(
|
||||
|
||||
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
||||
"""[기본 도각으로] — 프로젝트 `_initial/` 도각을 작업본에 덮어쓴다(재계산 아님).
|
||||
|
||||
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
||||
되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 되돌린 도각으로 나온다.
|
||||
"""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
removed = await asyncio.to_thread(clear_company_template, company_dir)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
|
||||
project_root = await _project_root(project_id)
|
||||
await asyncio.to_thread(reset_project_template, project_root)
|
||||
return FrameTemplateSaveResponse(
|
||||
project_id=str(project_id),
|
||||
customized=is_project_template_customized(project_root),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
|
||||
@@ -137,7 +137,7 @@ class FrameTemplateSaveRequest(BaseModel):
|
||||
|
||||
|
||||
class FrameTemplateSaveResponse(BaseModel):
|
||||
"""회사 도각 저장 결과."""
|
||||
"""프로젝트 도각 저장 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
|
||||
@@ -38,14 +38,20 @@ import {
|
||||
exportDrawing,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
invalidateDesignDrawing,
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
type CadDrawing,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
|
||||
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
|
||||
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
@@ -62,33 +68,13 @@ interface DesignMeta {
|
||||
}
|
||||
|
||||
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
||||
interface SaveResult {
|
||||
drawing: CadDrawing;
|
||||
quantityTable: QuantityTable | null;
|
||||
}
|
||||
|
||||
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const B07_CAD_APP_URL = "/b07-cad/index.html";
|
||||
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
||||
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
||||
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
||||
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
||||
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
||||
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
||||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 화면 조립
|
||||
* -------------------------------------------------------------------------- */
|
||||
@@ -114,71 +100,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
: "도면 목록을 불러오지 못했습니다.";
|
||||
}
|
||||
|
||||
const cadHost = document.createElement("div");
|
||||
cadHost.className = "b07-cad-host";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "b07-cad-frame";
|
||||
frame.src = B07_CAD_APP_URL;
|
||||
frame.title = L("B07_Design_Title");
|
||||
const license = document.createElement("a");
|
||||
license.className = "b07-cad-license";
|
||||
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
||||
license.target = "_blank";
|
||||
license.rel = "noreferrer";
|
||||
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
||||
cadHost.append(frame, license);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad:
|
||||
| {
|
||||
drawing: CadDrawing;
|
||||
meta: DesignMeta | null;
|
||||
frameEdit: boolean;
|
||||
frameFields: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
|
||||
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
|
||||
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
|
||||
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
|
||||
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
|
||||
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
|
||||
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
|
||||
const CAD_READY_TIMEOUT_MS = 20000;
|
||||
const CAD_LOAD_TIMEOUT_MS = 15000;
|
||||
let loadWatchdog: number | undefined;
|
||||
|
||||
const failCad = (detail: string): void => {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error = detail;
|
||||
showToast(detail, "error");
|
||||
};
|
||||
|
||||
const stopLoadWatchdog = (): void => {
|
||||
if (loadWatchdog === undefined) return;
|
||||
window.clearTimeout(loadWatchdog);
|
||||
loadWatchdog = undefined;
|
||||
};
|
||||
|
||||
const startLoadWatchdog = (): void => {
|
||||
stopLoadWatchdog();
|
||||
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
|
||||
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
|
||||
loadWatchdog = window.setTimeout(() => {
|
||||
loadWatchdog = undefined;
|
||||
if (cadHost.dataset.loading !== "true") return;
|
||||
failCad(
|
||||
cadReady
|
||||
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
|
||||
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
|
||||
);
|
||||
}, wait);
|
||||
};
|
||||
|
||||
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
|
||||
frame.addEventListener("error", () => {
|
||||
stopLoadWatchdog();
|
||||
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
|
||||
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
|
||||
title: L("B07_Design_Title"),
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
onChanged: (dirty) => {
|
||||
if (!frameEditor.isEditing()) cadDirty = dirty;
|
||||
},
|
||||
onNavigate: (direction) => navigateDrawing(direction),
|
||||
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
|
||||
});
|
||||
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
@@ -188,7 +119,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
@@ -262,23 +192,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
hasNext: index < drawings.length - 1,
|
||||
});
|
||||
|
||||
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
||||
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
|
||||
const sendLoad = (
|
||||
drawing: CadDrawing,
|
||||
meta: DesignMeta | null,
|
||||
frameEdit = false,
|
||||
frameFields: Record<string, string> = {},
|
||||
) => {
|
||||
pendingLoad = { drawing, meta, frameEdit, frameFields };
|
||||
if (!cadReady) return;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
|
||||
window.location.origin,
|
||||
);
|
||||
pendingLoad = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
|
||||
*
|
||||
@@ -349,9 +262,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cadHost.dataset.loading = "true";
|
||||
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
||||
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
|
||||
try {
|
||||
const response = await requestDrawing(drawing);
|
||||
currentDrawing = drawing;
|
||||
@@ -360,10 +271,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
|
||||
applyConfirmButtonState();
|
||||
updateInfoPanel(drawing, response);
|
||||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||||
cad.load(response.drawing, buildMeta(drawing, response, index));
|
||||
} catch (error) {
|
||||
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
||||
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
@@ -384,16 +294,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
void loadDrawing(drawings[target], target);
|
||||
};
|
||||
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
|
||||
}, 5000);
|
||||
});
|
||||
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
|
||||
|
||||
async function confirmCurrentDrawing(): Promise<void> {
|
||||
if (!projectId || !currentDrawing) return;
|
||||
@@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const frameEditor = createFrameTemplateEditor({
|
||||
projectId: projectId as string,
|
||||
sendLoad,
|
||||
const frameEditor = createFrameTemplateEditor<CadDrawing>({
|
||||
api: {
|
||||
fetch: () => fetchFrameTemplate(projectId as string),
|
||||
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
|
||||
importFile: (file) => importFrameTemplate(projectId as string, file),
|
||||
reset: () => resetFrameTemplate(projectId as string),
|
||||
},
|
||||
sendLoad: (drawing, meta, frameEdit, frameFields) =>
|
||||
cad.load(drawing, meta, frameEdit, frameFields),
|
||||
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
|
||||
restoreDrawing: () => {
|
||||
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
|
||||
@@ -513,77 +420,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
||||
});
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
drawing?: CadDrawing;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
fileFormat?: "dxf" | "dwg";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
if (message.type === CAD_TOAST_MESSAGE) {
|
||||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
kind ?? "info",
|
||||
duration,
|
||||
actionId
|
||||
? () =>
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
|
||||
window.location.origin,
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
} else if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) {
|
||||
sendLoad(
|
||||
pendingLoad.drawing,
|
||||
pendingLoad.meta,
|
||||
pendingLoad.frameEdit,
|
||||
pendingLoad.frameFields,
|
||||
);
|
||||
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
|
||||
if (cadHost.dataset.loading === "true") startLoadWatchdog();
|
||||
}
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
||||
void exportDrawingFile(message.fileFormat);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({
|
||||
drawing: message.drawing,
|
||||
quantityTable: message.quantityTable ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
@@ -604,7 +440,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
steps: workflowSteps(),
|
||||
activeStep: 4,
|
||||
leftPanel: drawingPanel,
|
||||
mainContent: cadHost,
|
||||
mainContent: cad.element,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
@@ -624,7 +460,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
if (drawings.length > 0) {
|
||||
void loadDrawing(drawings[0], 0).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
|
||||
@@ -157,76 +157,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* CAD 뷰어 호스트 (상세 페이지 영역) */
|
||||
.b07-cad-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
|
||||
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
|
||||
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
|
||||
.b07-cad-host[data-loading="true"]::after {
|
||||
content: "도면을 불러오는 중…";
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b07-cad-host[data-error]:not([data-error=""])::before {
|
||||
content: attr(data-error);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 70%;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-danger, #d33);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-danger, #d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* B07 독립형 CAD 앱 임베드 */
|
||||
.b07-cad-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.b07-cad-license {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
bottom: 7px;
|
||||
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.b07-cad-license:hover {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
|
||||
.b07-info-host:empty {
|
||||
display: none;
|
||||
@@ -294,40 +224,6 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
|
||||
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
|
||||
.b07-frame-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.b07-frame-edit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-frame-edit__label {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b07-frame-edit__buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-frame-edit__buttons > button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setFrameEditMode,
|
||||
setHostReadOnly,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -45,6 +46,10 @@ interface DrawingLoadMessage {
|
||||
frameEdit?: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
|
||||
frameFields?: Record<string, string>;
|
||||
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -187,6 +192,7 @@ export function registerAisloDrawingBridge() {
|
||||
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
||||
setHostReadOnly(event.data.readOnly === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
@@ -194,7 +200,7 @@ export function registerAisloDrawingBridge() {
|
||||
// 상태를 이어 갈 이유도 없다.
|
||||
runCommandInput('SELECT');
|
||||
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
|
||||
setRecoveryScope(event.data.meta?.drawingId ?? null);
|
||||
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
|
||||
} catch (error) {
|
||||
|
||||
@@ -190,6 +190,8 @@ let designMeta: DesignMeta | null = null;
|
||||
let frameEditMode = false;
|
||||
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
|
||||
let frameFields: Record<string, string> = {};
|
||||
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
|
||||
let hostReadOnly = false;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -267,7 +269,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 +495,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
|
||||
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 : {};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */
|
||||
|
||||
.m02-drawing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-drawing > .cad-host {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.m02-drawing__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
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__area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-drawing__area .ui-field {
|
||||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.m02-drawing__fields {
|
||||
display: flex;
|
||||
flex: 1 1 20rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.m02-drawing__field {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1440px;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-drawing__field[data-source="도면"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-drawing__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
|
||||
/** 도면 양식 문서 — 작도 영역은 [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 {
|
||||
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
|
||||
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
|
||||
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
interface DrawingField {
|
||||
key: string;
|
||||
source: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
async function fetchDrawingFields(): Promise<DrawingField[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" });
|
||||
const payload = (await response.json()) as { fields?: DrawingField[]; message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload.fields ?? [];
|
||||
}
|
||||
|
||||
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 = `m02:${options.name ?? "drawing"}`;
|
||||
let base: DrawingTemplateDoc = doc;
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "m02-drawing";
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "m02-drawing__toolbar";
|
||||
|
||||
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다.
|
||||
const area = document.createElement("div");
|
||||
area.className = "m02-drawing__area";
|
||||
const areaTitle = document.createElement("span");
|
||||
areaTitle.className = "m02-drawing__title";
|
||||
areaTitle.textContent = "작도 영역";
|
||||
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;
|
||||
return field;
|
||||
});
|
||||
area.append(areaTitle, ...areaInputs.map((field) => field.root));
|
||||
|
||||
const readArea = (): [number, number, number, number] | null => {
|
||||
const values = areaInputs.map((field) => Number(field.input.value));
|
||||
const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3];
|
||||
areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위"));
|
||||
return valid ? (values as [number, number, number, number]) : null;
|
||||
};
|
||||
|
||||
// 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다.
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "m02-drawing__fields";
|
||||
const fieldsTitle = document.createElement("span");
|
||||
fieldsTitle.className = "m02-drawing__title";
|
||||
fieldsTitle.textContent = "자리표";
|
||||
fields.append(fieldsTitle);
|
||||
void fetchDrawingFields()
|
||||
.then((list) => {
|
||||
for (const field of list) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "m02-drawing__field";
|
||||
chip.dataset.source = field.source;
|
||||
chip.textContent = `{{${field.key}}}`;
|
||||
chip.title = `${field.label} (${field.source}) — 눌러 복사`;
|
||||
chip.addEventListener("click", () => {
|
||||
void navigator.clipboard
|
||||
?.writeText(`{{${field.key}}}`)
|
||||
.then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success"));
|
||||
});
|
||||
fields.append(chip);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
|
||||
);
|
||||
|
||||
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
|
||||
const load = (drawing: DrawingTemplateDoc): void => {
|
||||
cad.beginLoading();
|
||||
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
if (options.onSave && !readOnly) {
|
||||
const onSave = options.onSave;
|
||||
const saveButton = createButton({
|
||||
label: "저장",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
saveButton.disabled = true;
|
||||
getDoc()
|
||||
.then((next) => onSave(next))
|
||||
.catch((error) =>
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
|
||||
"error",
|
||||
),
|
||||
)
|
||||
.finally(() => (saveButton.disabled = false));
|
||||
},
|
||||
});
|
||||
actions.append(saveButton);
|
||||
}
|
||||
|
||||
toolbar.append(area, fields, actions);
|
||||
root.append(toolbar, cad.element);
|
||||
container.append(root);
|
||||
load(doc);
|
||||
|
||||
return {
|
||||
getDoc,
|
||||
destroy: () => {
|
||||
cad.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,95 @@
|
||||
"""M02 마스터 템플릿 — 도면 길 틀(빈 라우터).
|
||||
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
|
||||
|
||||
⚠ 로그인만으로 `main.py` 에 붙음 — 권한은 길 안에서 봄. 길은 계약 `6_계약.md` 를 따름.
|
||||
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
|
||||
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Drawing"])
|
||||
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 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 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,10 +1,437 @@
|
||||
"""M02 마스터 템플릿 — 층 길 틀(빈 라우터).
|
||||
"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」.
|
||||
|
||||
⚠ 로그인만으로 `main.py` 에 붙음 — 권한은 길 안에서 봄. 길은 계약 `6_계약.md` 를 따름.
|
||||
⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서:
|
||||
system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길)
|
||||
company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자)
|
||||
personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기)
|
||||
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
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)
|
||||
try:
|
||||
version = await asyncio.to_thread(
|
||||
layers.write_template,
|
||||
folder,
|
||||
kind,
|
||||
name,
|
||||
body.문서,
|
||||
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.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,312 @@
|
||||
"""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("양식 이름이 올바르지 않습니다.")
|
||||
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 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) -> bool:
|
||||
path = template_path(layer_dir, kind, name)
|
||||
if not path.is_file():
|
||||
return False
|
||||
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 _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
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"format": 6,
|
||||
"source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)",
|
||||
"drawing_area": [42, 47, 812, 567],
|
||||
"entities": [
|
||||
{
|
||||
"id": "4afa84ae-9c15-50ec-8a76-db87d04d6311",
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"양식": "구조물집계표",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
|
||||
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값)",
|
||||
"층": ["종류", "공법", "규격"],
|
||||
"변수": {
|
||||
"파형강관_1본_m": 8,
|
||||
"흄관_1본_m": "",
|
||||
"VR관_1본_m": "",
|
||||
"수축줄눈_간격_m": 6,
|
||||
"측점간격_m": 20
|
||||
},
|
||||
"쪽줄": 50,
|
||||
"열": [
|
||||
{ "id": "no", "머리": ["NO", null, null], "단위": null, "꼴": "글", "설명": "줄 차례(전구간 줄 빼고 1부터)" },
|
||||
{ "id": "sta", "머리": ["측점", null, null], "단위": null, "꼴": "글", "설명": "점 NO.x+y · 구간 NO.a~NO.b · 맨 위 전구간" },
|
||||
|
||||
{
|
||||
"id": "rv", "머리": ["돌기슭막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 기슭막이 · 관 유입·유출 기슭막이 길이 · 형태 → 높이마다 열",
|
||||
"일위대가": "UA000004",
|
||||
"바인딩": {
|
||||
"종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv",
|
||||
"머리틀": ["돌기슭막이", "{0}", "H={1}"],
|
||||
"이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" },
|
||||
"더함": [
|
||||
{ "종류": "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", "머리틀": ["콘크리트포장", "T={0}cm", "B"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 포장 길이",
|
||||
"일위대가": "UA000007",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "L"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 확폭 면적",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "확폭"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"식": "IF([pv_l]>0,[pv_b]*[pv_l]+[pv_w],\"\")",
|
||||
"끝수": { "자리": 2, "방법": "반올림" },
|
||||
"설명": "계산 — B × L + 확폭",
|
||||
"일위대가": "UA000006",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "A"] }
|
||||
},
|
||||
{
|
||||
"id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")",
|
||||
"끝수": { "자리": 2, "방법": "반올림" },
|
||||
"설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)",
|
||||
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}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본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)",
|
||||
"바인딩": {
|
||||
"종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "식", "묶음": "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", "머리틀": ["물넘이포장", "T={0}cm", "폭"] }
|
||||
},
|
||||
{
|
||||
"id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 포장 길이(노폭 방향)",
|
||||
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}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}", "H={1}"] }
|
||||
},
|
||||
{
|
||||
"id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 돌쌓기 길이",
|
||||
"바인딩": {
|
||||
"종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "H={1}"],
|
||||
"더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만",
|
||||
"일위대가": "UA000032",
|
||||
"바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "H={1}"] }
|
||||
},
|
||||
{
|
||||
"id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 큰돌쌓기 길이",
|
||||
"바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "길이"] }
|
||||
},
|
||||
{
|
||||
"id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true,
|
||||
"설명": "계산 — L × H (H = 펼친 높이)",
|
||||
"일위대가": "UA000031",
|
||||
"바인딩": {
|
||||
"종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "식", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "면적"],
|
||||
"식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 골막이 개소",
|
||||
"바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null] }
|
||||
},
|
||||
{
|
||||
"id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수",
|
||||
"설명": "설계값 — 소단 길이",
|
||||
"바인딩": { "종류": "berm", "값": "길이" }
|
||||
},
|
||||
{ "id": "rf", "머리": ["대피소", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 대피소", "바인딩": { "종류": "refuge", "값": "개소" } },
|
||||
{ "id": "wy", "머리": ["정차·작업장", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 정차·작업장", "바인딩": { "종류": "work_yard", "값": "개소" } },
|
||||
{ "id": "ta", "머리": ["차돌림곳", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차돌림곳", "바인딩": { "종류": "turnaround", "값": "개소" } },
|
||||
{ "id": "gr_rail", "머리": ["가드레일", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 가드레일 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "가드레일" } } },
|
||||
{ "id": "gr_curb", "머리": ["경계석", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 경계석 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "경계석" } } },
|
||||
{ "id": "gr_sign", "머리": ["위험표지", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 위험표지", "일위대가": "UA000017", "바인딩": { "종류": "guardrail", "값": "개소", "조건": { "kind": "위험표지" } } },
|
||||
{ "id": "mr", "머리": ["반사경", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 반사경", "일위대가": "UA000018", "바인딩": { "종류": "mirror", "값": "개소" } },
|
||||
{ "id": "bg", "머리": ["차단기", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차단기", "일위대가": "UA000010", "바인딩": { "종류": "barrier_gate", "값": "개소" } },
|
||||
{ "id": "ps", "머리": ["국가지점번호판", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 번호판 · 전구간 줄은 손 입력", "일위대가": "UA000016", "바인딩": { "종류": "position_sign", "값": "개소" } },
|
||||
{ "id": "cs", "머리": ["준공표지석", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 준공표지판", "일위대가": "UA000002", "바인딩": { "종류": "completion_sign", "값": "개소" } },
|
||||
{
|
||||
"id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
|
||||
"설명": "설계값 — 기타 구조물 개소",
|
||||
"바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null] }
|
||||
},
|
||||
|
||||
{ "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" },
|
||||
{ "id": "h_rip_c", "머리": ["돌붙임", "찰붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
|
||||
{ "id": "h_rip_m", "머리": ["돌붙임", "메붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
|
||||
{ "id": "h_stone_ch", "머리": ["돌수로", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000011" },
|
||||
{ "id": "h_stone_bed", "머리": ["돌조공", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000023" },
|
||||
{ "id": "h_pv_rail", "머리": ["포장 난간", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000012" },
|
||||
{ "id": "h_pg_rail", "머리": ["관보호공 안전난간", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000013" },
|
||||
{ "id": "h_soil_ditch", "머리": ["수로형 토사개거", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000033" },
|
||||
{ "id": "h_ford_basin", "머리": ["물넘이 집수부", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000034" },
|
||||
{ "id": "h_rockfall", "머리": ["낙석방지책", null, null], "단위": "경간", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
{ "id": "h_break", "머리": ["콘크리트·포장 깨기", null, null], "단위": "㎥", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
{ "id": "h_waste", "머리": ["폐기물처리", null, null], "단위": "ton", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
|
||||
|
||||
{ "id": "memo", "머리": ["비고", null, null], "단위": null, "꼴": "글", "손": true, "설명": "손 입력" }
|
||||
],
|
||||
"줄": [
|
||||
{ "id": "all", "값": { "sta": "전구간" }, "고정": "전구간" }
|
||||
],
|
||||
"합계줄": [
|
||||
{ "id": "sum", "이름": "합계", "식": "SUM" }
|
||||
],
|
||||
"보기": { "틀고정": { "열": 2 }, "열너비": { "no": 40, "sta": 150, "memo": 120 } }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, FRAME_LAYER_ID
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
|
||||
TEMPLATE = Path("resources/template_2dDrawing/00_template_cover.json")
|
||||
TEMPLATE = Path("resources/master_template/drawing/00_template_cover.json")
|
||||
|
||||
# A1 도각 템플릿과 같은 재단 표식 원점 — 두 도면이 같은 종이 좌표계를 써야 한다.
|
||||
PAPER = (-5.05, -5.04, 834.95, 588.96)
|
||||
@@ -62,9 +62,7 @@ def test_도면은_잠금_도각_레이어로_나온다():
|
||||
assert {e["layerId"] for e in drawing["entities"]} == {FRAME_LAYER_ID}
|
||||
layers = {layer["id"]: layer for layer in drawing["layers"]}
|
||||
assert layers[FRAME_LAYER_ID]["isLocked"] is True
|
||||
assert any(not layer["isLocked"] for layer in drawing["layers"]), (
|
||||
"덧그릴 비잠금 도면층이 없다"
|
||||
)
|
||||
assert any(not layer["isLocked"] for layer in drawing["layers"]), "덧그릴 비잠금 도면층이 없다"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""M02 도면 양식 — 작도 영역 칸 · B07 도각 층(프로젝트 작업본 → 시스템) (PLAN 10-3)."""
|
||||
|
||||
import json
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
A1 = engine.A1_TEMPLATE
|
||||
|
||||
|
||||
def _project_template(project_root, document):
|
||||
path = engine.project_template_path(project_root)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _entities(project_root):
|
||||
engine.use_project_templates(project_root)
|
||||
try:
|
||||
return engine.template_entities()
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_system_a1_reads_drawing_area():
|
||||
engine.use_project_templates(None)
|
||||
assert engine.drawing_area() == (42.0, 47.0, 812.0, 567.0)
|
||||
assert len(engine.template_entities()) == 51
|
||||
|
||||
|
||||
def test_missing_area_falls_back_to_a1(tmp_path):
|
||||
_project_template(tmp_path, {"format": 6, "entities": [], "layers": []})
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == engine._A1_INNER
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_template_area_moves_content(tmp_path):
|
||||
line = {
|
||||
"id": "a",
|
||||
"type": "Line",
|
||||
"shapeData": {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 10, "y": 0}},
|
||||
}
|
||||
_project_template(
|
||||
tmp_path,
|
||||
{"format": 6, "drawing_area": [0, 0, 400, 200], "entities": [line], "layers": []},
|
||||
)
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == (0.0, 0.0, 400.0, 200.0)
|
||||
assert engine.usable_area() == (400 * 0.96, 200 * 0.96)
|
||||
# 콘텐츠 중심(500, 500) 이 작도 영역 중심(200, 100) 에 온다 — 이동량 (300, 400).
|
||||
placed = engine.frame_entities("t", (490.0, 490.0, 510.0, 510.0), fit=False)
|
||||
assert placed[0]["shapeData"]["startPoint"] == {"x": 300.0, "y": 400.0}
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_bad_area_falls_back(tmp_path):
|
||||
_project_template(tmp_path, {"drawing_area": [10, 10, 5, 5], "entities": []})
|
||||
engine.use_project_templates(tmp_path)
|
||||
try:
|
||||
assert engine.drawing_area() == engine._A1_INNER
|
||||
finally:
|
||||
engine.use_project_templates(None)
|
||||
|
||||
|
||||
def test_old_project_reads_system(tmp_path):
|
||||
# 작업본이 없는 옛 프로젝트 — 시스템 양식으로 떨어진다.
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
|
||||
def test_save_reset_on_seeded_project(tmp_path):
|
||||
layers.seed_project(tmp_path)
|
||||
assert engine.project_template_path(tmp_path).is_file()
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
# [완료] — 작업본에만 쓴다 · 작도 영역은 이어 받는다 · `_initial/` · 시스템은 그대로.
|
||||
path = engine.save_project_template(tmp_path, [])
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert saved["drawing_area"] == [42, 47, 812, 567]
|
||||
assert _entities(tmp_path) == []
|
||||
assert engine.is_project_template_customized(tmp_path) is True
|
||||
initial = layers.template_path(layers.initial_dir(tmp_path), "drawing", A1)
|
||||
assert len(json.loads(initial.read_text(encoding="utf-8"))["entities"]) == 51
|
||||
assert (
|
||||
len(json.loads(engine.system_template_path().read_text(encoding="utf-8"))["entities"]) == 51
|
||||
)
|
||||
|
||||
# [기본 도각으로] — `_initial/` 을 작업본에 덮어쓴다.
|
||||
assert engine.reset_project_template(tmp_path) is True
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.is_project_template_customized(tmp_path) is False
|
||||
|
||||
|
||||
def test_reset_on_old_project_drops_to_system(tmp_path):
|
||||
engine.save_project_template(tmp_path, [])
|
||||
assert _entities(tmp_path) == []
|
||||
assert engine.reset_project_template(tmp_path) is True
|
||||
assert not engine.project_template_path(tmp_path).exists()
|
||||
assert len(_entities(tmp_path)) == 51
|
||||
assert engine.reset_project_template(tmp_path) is False
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,183 @@
|
||||
"""M02 양식 층 — 자리 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 (임시 storage)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,104 @@
|
||||
/* ui_template/cad_host — 웹캐드 iframe 칸 · 도각 편집 띠 (B07 · M02 공용). */
|
||||
|
||||
/* CAD 뷰어 호스트 (상세 페이지 영역) */
|
||||
.cad-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
|
||||
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
|
||||
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
|
||||
.cad-host[data-loading="true"]::after {
|
||||
content: "도면을 불러오는 중…";
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cad-host[data-error]:not([data-error=""])::before {
|
||||
content: attr(data-error);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 70%;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-danger, #d33);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-danger, #d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* B07 독립형 CAD 앱 임베드 */
|
||||
.cad-host__frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cad-host__license {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
bottom: 7px;
|
||||
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cad-host__license:hover {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
|
||||
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
|
||||
.cad-frame-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.cad-frame-edit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cad-frame-edit__label {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cad-frame-edit__buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.cad-frame-edit__buttons > button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
|
||||
export interface CadLoadExtra {
|
||||
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
|
||||
readOnly?: 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_EXPORT_MESSAGE && message.fileFormat) {
|
||||
options.onExport?.(message.fileFormat);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
return {
|
||||
element,
|
||||
beginLoading: () => {
|
||||
element.dataset.loading = "true";
|
||||
element.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
||||
},
|
||||
fail: (detail) => {
|
||||
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
||||
showFailure(detail);
|
||||
},
|
||||
load,
|
||||
requestSave,
|
||||
destroy: () => {
|
||||
stopLoadWatchdog();
|
||||
window.removeEventListener("message", onMessage);
|
||||
},
|
||||
};
|
||||
}
|
||||
+38
-30
@@ -1,21 +1,27 @@
|
||||
/**
|
||||
* B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
|
||||
* 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
|
||||
* B07 에서 떼어 공용으로 둔다 — 도각을 읽고 쓰는 길(`api`)만 페이지가 넘긴다.
|
||||
*
|
||||
* 정본(`resources/template_2dDrawing/00_template_A1.json`)은 프로그램 기본 도각이라
|
||||
* 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고,
|
||||
* 이후 그리는 도면이 그것을 쓴다 (2026-09-01 사용자 확정).
|
||||
* 시스템 양식(`resources/master_template/drawing/00_template_A1.json`)은 건드리지 않는다.
|
||||
* 고친 도각은 **프로젝트 도각**(작업본 `{프로젝트}/templates/drawing/`)으로 저장되고,
|
||||
* 이후 그리는 도면이 그것을 쓴다 (PLAN 10-5).
|
||||
*
|
||||
* 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다시 그려진다.
|
||||
*/
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
type CadDrawing,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import type { CadHostDrawing } from "./cad_host";
|
||||
|
||||
/** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */
|
||||
export interface FrameTemplateApi<D extends CadHostDrawing> {
|
||||
/** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */
|
||||
fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record<string, string> }>;
|
||||
save: (drawing: D) => Promise<void>;
|
||||
/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */
|
||||
importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>;
|
||||
/** 고친 도각을 지우고 기본 도각으로 되돌린다. */
|
||||
reset: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FrameTemplateEditor {
|
||||
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
|
||||
@@ -26,18 +32,18 @@ export interface FrameTemplateEditor {
|
||||
isEditing: () => boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
projectId: string;
|
||||
interface Options<D extends CadHostDrawing> {
|
||||
api: FrameTemplateApi<D>;
|
||||
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
|
||||
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
|
||||
sendLoad: (
|
||||
drawing: CadDrawing,
|
||||
drawing: D,
|
||||
meta: null,
|
||||
frameEdit?: boolean,
|
||||
frameFields?: Record<string, string>,
|
||||
) => void;
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<CadDrawing>;
|
||||
requestCadDrawing: () => Promise<D>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
restoreDrawing: () => void;
|
||||
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
|
||||
@@ -46,16 +52,18 @@ interface Options {
|
||||
currentDrawingInfo: () => { label: string; number: string } | null;
|
||||
}
|
||||
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
export function createFrameTemplateEditor<D extends CadHostDrawing>(
|
||||
options: Options<D>,
|
||||
): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
|
||||
let frameFields: Record<string, string> = {};
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "b07-frame-edit";
|
||||
banner.className = "cad-frame-edit";
|
||||
banner.hidden = true;
|
||||
const label = document.createElement("span");
|
||||
label.className = "b07-frame-edit__label";
|
||||
label.className = "cad-frame-edit__label";
|
||||
banner.append(label);
|
||||
|
||||
const finishButton = createButton({
|
||||
@@ -64,7 +72,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
onClick: () => void finish(),
|
||||
});
|
||||
/**
|
||||
* 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설).
|
||||
* 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다 (2026-09-01 신설).
|
||||
* 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
*/
|
||||
const resetButton = createButton({
|
||||
@@ -75,7 +83,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
/**
|
||||
* 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가
|
||||
* 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직
|
||||
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
|
||||
*/
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
@@ -95,7 +103,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
onClick: () => leave(),
|
||||
});
|
||||
const bannerButtons = document.createElement("div");
|
||||
bannerButtons.className = "b07-frame-edit__buttons";
|
||||
bannerButtons.className = "cad-frame-edit__buttons";
|
||||
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
|
||||
banner.append(bannerButtons, fileInput);
|
||||
|
||||
@@ -118,7 +126,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
if (!file) return;
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
const response = await importFrameTemplate(options.projectId, file);
|
||||
const response = await options.api.importFile(file);
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
|
||||
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
|
||||
@@ -134,7 +142,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
|
||||
async function enter(): Promise<void> {
|
||||
try {
|
||||
const response = await fetchFrameTemplate(options.projectId);
|
||||
const response = await options.api.fetch();
|
||||
// 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다.
|
||||
const info = options.currentDrawingInfo();
|
||||
frameFields = {
|
||||
@@ -145,8 +153,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
button.disabled = true;
|
||||
banner.hidden = false;
|
||||
label.textContent = response.customized
|
||||
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
? "도각 편집 중 — 이 프로젝트에서 고친 도각입니다."
|
||||
: "도각 편집 중 — 고치면 이 프로젝트 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
@@ -156,16 +164,16 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
async function resetToDefault(): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" +
|
||||
"확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?",
|
||||
"이 프로젝트 도각을 처음 복사한 도각으로 되돌립니다.\n" +
|
||||
"확정하지 않은 도면부터 되돌린 도각으로 나옵니다. 계속할까요?",
|
||||
)
|
||||
)
|
||||
return;
|
||||
resetButton.disabled = true;
|
||||
try {
|
||||
await resetFrameTemplate(options.projectId);
|
||||
await options.api.reset();
|
||||
options.onSaved();
|
||||
showToast("기본 도각으로 되돌렸습니다.", "success");
|
||||
showToast("처음 복사한 도각으로 되돌렸습니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
@@ -182,7 +190,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
finishButton.disabled = true;
|
||||
try {
|
||||
const drawing = await options.requestCadDrawing();
|
||||
await saveFrameTemplate(options.projectId, drawing);
|
||||
await options.api.save(drawing);
|
||||
options.onSaved();
|
||||
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
|
||||
leave();
|
||||
Reference in New Issue
Block a user