사용자 지시(2026-09-02) — 로고·서명을 임의로 만들어 표제란에 넣고, 위치·크기는
도각을 고치면 따라오게 하며, 시행청·과업책임자 등은 DB를 넓혀 채울 것.
도각(표제란)
- `00_template_A1.json` 에 그림 자리 2개 추가 — 회사 로고는 용역회사 칸 왼쪽
(316~348 × 20~36mm), 설계자 서명은 설계 칸 아래(638~680 × 17.5~25.5mm).
자리·크기가 **템플릿 좌표로만** 정해지므로 도각을 고치면 그대로 따라옴.
- `_fill_placeholders` 가 그림 자리(`imageData`)의 `{{키}}` 도 채움. 값을 못 구하면
빈 문자열을 남기지 않고 **엔티티째 제거** — 빈 값은 CAD 가 깨진 그림으로 그림.
- `_transform_entity` 가 `points` 배열도 옮김. 그림(로고·서명)과 띠(Hatch)가 이 키를
쓰는데 여태 변환 대상이 아니라 도각을 옮기면 제자리에 남았음.
DB (`011_title_block.sql`, 전부 ADD COLUMN·NULL 허용)
- `projects` — `client_org`(시행청), `pm_user_id`·`field_lead_user_id`·`designer_user_id`
- `companies.logo_path` · `users.signature_path` — 그림은 파일로 두고 경로만 담음
(기존 `storage_path`·`input_files` 와 같은 방식)
- FK 는 걸지 않음 — 사유를 파일 머리말에 적음(소프트 삭제·LEFT JOIN·4환경 공유).
배선
- `_title_block_fields` 가 시행청·과업책임자·분야별책임자·설계자(배정 없으면 소유자)와
로고·서명 data URL 까지 실어 보냄.
- `read_stored_asset()` 신설 — `storage/` 기준 상대 경로의 **파일**을 읽음.
`resolve_stored_project_path()` 는 폴더를 만드는 프로젝트 루트용이라 파일에 못 씀.
CAD 결함 1건 (이번 작업에서 드러남)
- `screenCanvas.drawController.drawImage` 가 `(width, height)` 를 좌표처럼 변환해
화면 오프셋과 y 뒤집기가 섞여 들어갔음 — 그림이 제 자리를 벗어나고 비율이 무너짐.
크기는 배율만 곱하고 자리는 세계 중심으로 잡도록 고침(SVG 컨트롤러와 같은 방식).
- 검증 창구 `__aisloCad.screen(x, y)` 추가 — 세계→화면 좌표. 그림·글자가 제 자리에
그려졌는지 픽셀로 판정할 때 씀.
검증: `pytest tmp/tests/ -q` 135 passed / 0 failed(그림 자리 3건 신규),
`npx vitest run` 87 passed / 0 failed, `check-types`·`build` 통과.
화면 실측(5174, wdw): 표준도 API Text 24개 `{{` 잔존 0, Image 2개가 도각 좌표
(316,20)-(348,36)·(638,18)-(680,26)에 실림. 캔버스 픽셀 판정 — 두 자리 모두 배경색
외 픽셀이 그려짐(로고 88px·서명 64px), 로고 파랑(20,70,140)이 슬롯 안에서만 검출.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
521 lines
24 KiB
Python
521 lines
24 KiB
Python
"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from base64 import b64encode
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B06_Section.B06_Section_Repository import (
|
|
get_confirmed_route_context,
|
|
get_cross_section_design,
|
|
get_cross_section_designs,
|
|
get_longitudinal_section,
|
|
merge_cross_section_design_by_round,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
|
CROSS_SHEET_ID,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
|
extract_quantity_table,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
|
clear_company_template,
|
|
company_template_path,
|
|
frame_template_document,
|
|
save_company_template,
|
|
use_company_templates,
|
|
use_title_fields,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
|
MASS_HAUL_ID,
|
|
WATERSHED_ID,
|
|
_cross_sheet_plan,
|
|
_drawing_list,
|
|
_invalidate_drawing,
|
|
_read_drawing,
|
|
_recompute_confirmed_design,
|
|
_store_confirmed_drawing,
|
|
watershed_source,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
|
DesignDrawingConfirmRequest,
|
|
DesignDrawingConfirmResponse,
|
|
DesignDrawingInvalidateResponse,
|
|
DesignDrawingListResponse,
|
|
DesignDrawingResponse,
|
|
FrameTemplateResponse,
|
|
FrameTemplateSaveRequest,
|
|
FrameTemplateSaveResponse,
|
|
)
|
|
from common_util.common_util_drainage_context import load_drainage_context
|
|
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
|
|
from common_util.common_util_workflow_state import complete_stage, start_stage
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
|
|
|
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
|
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
|
|
|
|
|
async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
|
|
"""확정된 B06 종단 레코드와 프로젝트 저장 경로를 반환한다."""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
route_context = await get_confirmed_route_context(connection, project_id)
|
|
if not route_context:
|
|
raise FileNotFoundError("확정된 경로가 없습니다.")
|
|
route_id = int(route_context["route_id"])
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
|
if not longitudinal or longitudinal.get("status") != "CONFIRMED":
|
|
raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.")
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
|
|
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
|
longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve()
|
|
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
|
|
raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.")
|
|
return route_id, root, longitudinal_path
|
|
|
|
|
|
async def _company_dir(project_id: UUID) -> Path:
|
|
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`.
|
|
|
|
회사 도각은 회사 폴더에 산다(로고·서명과 같은 결). DB를 한 번 더 뒤지지 않는다.
|
|
"""
|
|
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
|
|
|
|
|
|
def _asset_data_url(relative_path: str | None) -> str:
|
|
"""회사 로고·개인 서명 파일을 CAD `ImageEntity`가 읽는 data URL로 만든다.
|
|
|
|
파일이 없거나 경로가 수상하면 빈 문자열 — 그 자리는 그림째 빠진다
|
|
(`_fill_placeholders`). 그림은 DB에 경로만 담는 기존 방식 그대로다.
|
|
"""
|
|
blob = read_stored_asset(relative_path)
|
|
if not blob:
|
|
return ""
|
|
suffix = PurePosixPath(str(relative_path)).suffix.lower()
|
|
mime = "image/svg+xml" if suffix == ".svg" else f"image/{suffix.lstrip('.') or 'png'}"
|
|
return f"data:{mime};base64,{b64encode(blob).decode('ascii')}"
|
|
|
|
|
|
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
|
|
|
|
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
|
|
박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적).
|
|
|
|
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
|
|
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
|
|
|
|
아직 못 채우는 자리와 이유:
|
|
- 축척(A1/A3)·사업량·연도기번 — 값을 지어내지 않는다(임의 수치 금지).
|
|
- 설계일자 — "확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다.
|
|
채울 시점 정의가 미결이라 비워 둔다.
|
|
- 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다
|
|
횡단 장 계획을 재계산하게 된다).
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT p.name, p.region, p.client_org, c.name, c.logo_path,
|
|
COALESCE(designer.name, owner.name),
|
|
COALESCE(designer.signature_path, owner.signature_path),
|
|
pm.name, lead.name
|
|
FROM projects p
|
|
LEFT JOIN companies c ON c.id = p.company_id
|
|
LEFT JOIN users owner ON owner.id = p.user_id
|
|
LEFT JOIN users designer ON designer.id = p.designer_user_id
|
|
LEFT JOIN users pm ON pm.id = p.pm_user_id
|
|
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
|
|
WHERE p.id = %s AND p.deleted_at IS NULL
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return {}
|
|
name, region, client_org, company, logo_path, designer, signature_path, pm, lead = row
|
|
fields = {
|
|
"공사명": name,
|
|
"위치": region,
|
|
"시행청": client_org,
|
|
"용역회사": company,
|
|
"설계자": designer,
|
|
"과업책임자": pm,
|
|
"분야별책임자": lead,
|
|
"회사로고": _asset_data_url(logo_path),
|
|
"설계자서명": _asset_data_url(signature_path),
|
|
}
|
|
return {key: str(value) for key, value in fields.items() if value}
|
|
|
|
|
|
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
|
|
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
rows = await get_cross_section_designs(connection, route_id)
|
|
return {
|
|
int(round(float(row["chainage_m"]))): row["design"]
|
|
for row in rows
|
|
if isinstance(row, dict) and isinstance(row.get("design"), dict)
|
|
}
|
|
|
|
|
|
@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse)
|
|
async def get_design_drawing_list(
|
|
project_id: UUID,
|
|
) -> DesignDrawingListResponse | JSONResponse:
|
|
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
|
|
try:
|
|
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
|
|
designs = await _designs_by_chainage(route_id)
|
|
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
|
|
return DesignDrawingListResponse(
|
|
project_id=str(project_id), route_id=route_id, drawings=drawings
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except PermissionError as exc:
|
|
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도면 목록 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "상세 설계 도면 목록을 읽지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/design-drawings/{drawing_id}", response_model=DesignDrawingResponse)
|
|
async def get_design_drawing(
|
|
project_id: UUID, drawing_id: str
|
|
) -> DesignDrawingResponse | JSONResponse:
|
|
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
|
|
try:
|
|
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
|
|
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
|
|
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
|
|
use_company_templates(project_root.parent.parent)
|
|
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
|
use_title_fields(await _title_block_fields(project_id))
|
|
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
|
design: dict[str, Any] | None = None
|
|
source_design: Any = None
|
|
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
|
if cross_match:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
design = await get_cross_section_design(
|
|
connection, route_id, int(cross_match.group(1))
|
|
)
|
|
source_design = design
|
|
elif CROSS_SHEET_ID.fullmatch(drawing_id):
|
|
# 장은 여러 측점을 담으므로 노선 전체 지정을 한 번에 읽어 넘긴다.
|
|
source_design = await _designs_by_chainage(route_id)
|
|
elif drawing_id == MASS_HAUL_ID:
|
|
# 유토곡선은 B06 확정 시 종단 레코드에 저장해 둔 산출물을 그대로 쓴다.
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
section = await get_longitudinal_section(connection, project_id, route_id)
|
|
source_design = ((section or {}).get("data") or {}).get("mass_haul")
|
|
elif drawing_id == WATERSHED_ID:
|
|
# 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다.
|
|
context, reason = await load_drainage_context(project_id)
|
|
if context is None:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
|
source_design = await asyncio.to_thread(watershed_source, context)
|
|
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
|
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
|
|
)
|
|
return DesignDrawingResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
id=drawing_id,
|
|
kind=kind,
|
|
label=label,
|
|
drawing=drawing,
|
|
confirmed=confirmed,
|
|
quantity_table=quantity_table,
|
|
design=design,
|
|
)
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except PermissionError as exc:
|
|
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception(
|
|
"B07 단건 도면 조회 실패: project_id=%s drawing_id=%s", project_id, drawing_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "상세 설계 도면을 읽지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/{project_id}/design-drawings/{drawing_id}/confirm",
|
|
response_model=DesignDrawingConfirmResponse,
|
|
)
|
|
async def confirm_design_drawing(
|
|
project_id: UUID, drawing_id: str, request: DesignDrawingConfirmRequest
|
|
) -> DesignDrawingConfirmResponse | JSONResponse:
|
|
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
|
|
|
|
횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
|
|
"""
|
|
try:
|
|
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
|
|
designs = await _designs_by_chainage(route_id)
|
|
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
|
|
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
|
|
if not item:
|
|
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
|
|
sheet = None
|
|
if CROSS_SHEET_ID.fullmatch(drawing_id):
|
|
plan = await asyncio.to_thread(
|
|
_cross_sheet_plan, project_root, longitudinal_path, designs
|
|
)
|
|
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
|
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
|
|
# 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}").
|
|
quantity_table = None
|
|
quantity_tables: dict[str, Any] = {}
|
|
if sheet is not None:
|
|
for chainage in sheet["chainages"]:
|
|
table = extract_quantity_table(f"{drawing_id}:{chainage}", request.drawing)
|
|
if table:
|
|
quantity_tables[str(chainage)] = table
|
|
elif item.kind == "cross":
|
|
quantity_table = (
|
|
extract_quantity_table(drawing_id, request.drawing) or request.quantity_table
|
|
)
|
|
# 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관.
|
|
all_confirmed = await asyncio.to_thread(
|
|
_store_confirmed_drawing,
|
|
project_root,
|
|
item,
|
|
request.drawing,
|
|
{candidate.id for candidate in items if candidate.kind == "cross"},
|
|
quantity_table,
|
|
quantity_tables or None,
|
|
)
|
|
|
|
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
|
|
# 장은 담긴 측점 전부를 함께 확정한다.
|
|
recomputed: list[tuple[int, dict[str, Any]]] = []
|
|
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
|
pool = get_db_pool()
|
|
targets: list[int] = []
|
|
if sheet is not None:
|
|
targets = list(sheet["chainages"])
|
|
elif item.kind == "cross" and cross_match:
|
|
targets = [int(cross_match.group(1))]
|
|
for chainage_int in targets:
|
|
designation = designs.get(chainage_int)
|
|
if not designation:
|
|
continue
|
|
try:
|
|
recomputed.append(
|
|
(
|
|
chainage_int,
|
|
await asyncio.to_thread(
|
|
_recompute_confirmed_design,
|
|
longitudinal_path,
|
|
f"cross_{chainage_int:05d}m",
|
|
designation,
|
|
),
|
|
)
|
|
)
|
|
except (ValueError, KeyError, FileNotFoundError, OSError):
|
|
logger.warning(
|
|
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
|
|
drawing_id,
|
|
chainage_int,
|
|
exc_info=True,
|
|
)
|
|
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
for chainage_int, confirmed_design in recomputed:
|
|
await merge_cross_section_design_by_round(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_int=chainage_int,
|
|
patch=confirmed_design,
|
|
)
|
|
async with connection.cursor() as cursor:
|
|
if all_confirmed:
|
|
await complete_stage(cursor, str(project_id), 4)
|
|
else:
|
|
await start_stage(cursor, str(project_id), 4)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return DesignDrawingConfirmResponse(
|
|
project_id=str(project_id),
|
|
id=drawing_id,
|
|
confirmed=True,
|
|
all_confirmed=all_confirmed,
|
|
design=recomputed[0][1] if len(recomputed) == 1 else None,
|
|
)
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except PermissionError as exc:
|
|
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도면 확정 실패: project_id=%s drawing_id=%s", project_id, drawing_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "상세 설계 도면을 확정하지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{project_id}/design-drawings/{drawing_id}/invalidate",
|
|
response_model=DesignDrawingInvalidateResponse,
|
|
)
|
|
async def invalidate_design_drawing(
|
|
project_id: UUID, drawing_id: str
|
|
) -> DesignDrawingInvalidateResponse | JSONResponse:
|
|
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
|
|
try:
|
|
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
|
|
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
|
if drawing_id not in {item.id for item in items}:
|
|
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
|
|
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
|
|
|
|
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
|
stale: list[int] = []
|
|
if cross_match:
|
|
stale = [int(cross_match.group(1))]
|
|
elif CROSS_SHEET_ID.fullmatch(drawing_id):
|
|
designs = await _designs_by_chainage(route_id)
|
|
plan = await asyncio.to_thread(
|
|
_cross_sheet_plan, project_root, longitudinal_path, designs
|
|
)
|
|
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
|
stale = list(sheet["chainages"]) if sheet else []
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
|
|
for chainage_int in stale:
|
|
await merge_cross_section_design_by_round(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_int=chainage_int,
|
|
patch={"status": "provisional"},
|
|
)
|
|
async with connection.cursor() as cursor:
|
|
await start_stage(cursor, str(project_id), 4)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return DesignDrawingInvalidateResponse(
|
|
project_id=str(project_id),
|
|
id=drawing_id,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except PermissionError as exc:
|
|
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception(
|
|
"B07 도면 확정 해제 실패: project_id=%s drawing_id=%s", project_id, drawing_id
|
|
)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."},
|
|
)
|
|
|
|
|
|
@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)
|
|
return FrameTemplateResponse(
|
|
project_id=str(project_id),
|
|
drawing=frame_template_document(),
|
|
customized=company_template_path(company_dir).is_file(),
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도각을 읽지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
|
async def put_frame_template(
|
|
project_id: UUID, request: FrameTemplateSaveRequest
|
|
) -> FrameTemplateSaveResponse | JSONResponse:
|
|
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
|
|
|
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
|
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
|
"""
|
|
try:
|
|
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)
|
|
return FrameTemplateSaveResponse(project_id=str(project_id))
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 저장 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도각을 저장하지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
|
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
|
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
|
|
|
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
|
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
|
"""
|
|
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)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."},
|
|
)
|