561 lines
22 KiB
Python
561 lines
22 KiB
Python
"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID, uuid5
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
|
get_confirmed_route_context,
|
|
get_longitudinal_section,
|
|
)
|
|
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import (
|
|
DesignDrawingConfirmRequest,
|
|
DesignDrawingConfirmResponse,
|
|
DesignDrawingInvalidateResponse,
|
|
DesignDrawingItem,
|
|
DesignDrawingListResponse,
|
|
DesignDrawingResponse,
|
|
)
|
|
from common_util.common_util_storage import 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$")
|
|
_GROUND_LAYER_ID = "b07-ground"
|
|
_STAGE_DIR = "B07_wf4_DesignDetail"
|
|
|
|
|
|
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
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.")
|
|
return payload
|
|
|
|
|
|
def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]:
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
if not cross_dir.is_dir():
|
|
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
|
stations = longitudinal.get("stations")
|
|
valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else [])
|
|
files = sorted(cross_dir.glob("cross_*.json"))
|
|
if valid_names:
|
|
files = [path for path in files if path.name in valid_names]
|
|
return files
|
|
|
|
|
|
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
|
stations = longitudinal.get("stations", [])
|
|
if not isinstance(stations, list):
|
|
return {}
|
|
return {
|
|
round(float(station.get("chainage_m", 0))): station
|
|
for station in stations
|
|
if isinstance(station, dict)
|
|
}
|
|
|
|
|
|
def _design_root(project_root: Path) -> Path:
|
|
return project_root / _STAGE_DIR
|
|
|
|
|
|
def _read_manifest(project_root: Path) -> dict[str, Any]:
|
|
path = _design_root(project_root) / "manifest.json"
|
|
if not path.is_file():
|
|
return {"drawings": {}}
|
|
payload = _read_json(path)
|
|
return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}}
|
|
|
|
|
|
def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
|
stage_root = _design_root(project_root)
|
|
stage_root.mkdir(parents=True, exist_ok=True)
|
|
path = stage_root / "manifest.json"
|
|
temporary = path.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
|
|
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
|
|
longitudinal = _read_json(longitudinal_path)
|
|
station_by_chainage = _station_map(longitudinal)
|
|
manifest_drawings = _read_manifest(project_root)["drawings"]
|
|
drawings = [
|
|
DesignDrawingItem(
|
|
id="longitudinal",
|
|
kind="longitudinal",
|
|
label="종단도 전체",
|
|
confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")),
|
|
)
|
|
]
|
|
for path in _cross_files(longitudinal_path, longitudinal):
|
|
match = _CROSS_ID.fullmatch(path.stem)
|
|
if not match:
|
|
continue
|
|
chainage = int(match.group(1))
|
|
station = station_by_chainage.get(chainage, {})
|
|
drawings.append(
|
|
DesignDrawingItem(
|
|
id=path.stem,
|
|
kind="cross",
|
|
label=str(station.get("label") or f"STA.{chainage // 1000}+{chainage % 1000:03d}"),
|
|
chainage_m=float(station.get("chainage_m", chainage)),
|
|
confirmed=bool(manifest_drawings.get(path.stem, {}).get("confirmed")),
|
|
)
|
|
)
|
|
return drawings
|
|
|
|
|
|
def _line_entity(
|
|
drawing_id: str,
|
|
index: int,
|
|
start: tuple[float, float],
|
|
end: tuple[float, float],
|
|
layer_id: str = _GROUND_LAYER_ID,
|
|
color: str = "#f5f7fa",
|
|
) -> dict[str, Any]:
|
|
entity_id = str(uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{index}"))
|
|
return {
|
|
"id": entity_id,
|
|
"type": "Line",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": layer_id,
|
|
"shapeData": {
|
|
"startPoint": {"x": start[0], "y": start[1]},
|
|
"endPoint": {"x": end[0], "y": end[1]},
|
|
},
|
|
}
|
|
|
|
|
|
def _text_entity(
|
|
drawing_id: str,
|
|
index: int,
|
|
label: str,
|
|
point: tuple[float, float],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": str(
|
|
uuid5(
|
|
UUID("8ce96e1d-17e8-457b-b46e-329456701225"),
|
|
f"{drawing_id}:{index}",
|
|
)
|
|
),
|
|
"type": "Text",
|
|
"lineColor": "#cbd5e1",
|
|
"lineWidth": 1,
|
|
"layerId": "b07-quantity-table",
|
|
"shapeData": {
|
|
"label": label,
|
|
"basePoint": {"x": point[0], "y": point[1]},
|
|
"options": {
|
|
"textDirection": {"x": 1, "y": 0},
|
|
"textAlign": "left",
|
|
"textColor": "#cbd5e1",
|
|
"fontSize": 0.32,
|
|
"fontFamily": "Noto Sans KR",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _quantity_rows(source: dict[str, Any]) -> list[tuple[str, str, str]]:
|
|
ground = source.get("center_z")
|
|
planned = source.get("planned_elevation_m", source.get("design_elevation_m"))
|
|
cut = (
|
|
max(float(ground) - float(planned), 0.0)
|
|
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
|
|
else None
|
|
)
|
|
fill = (
|
|
max(float(planned) - float(ground), 0.0)
|
|
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
|
|
else None
|
|
)
|
|
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
|
|
|
|
def value(number: Any) -> str:
|
|
return f"{float(number):.3f}" if isinstance(number, (int, float)) else "-"
|
|
|
|
return [
|
|
("기본", "측점", str(source.get("label", "-"))),
|
|
("기본", "지반고", value(ground)),
|
|
("기본", "계획고", value(planned)),
|
|
("기본", "절토고", value(cut)),
|
|
("기본", "성토고", value(fill)),
|
|
("흙깎기", "토사", value(quantities.get("cut_soil"))),
|
|
("흙깎기", "연암", value(quantities.get("cut_soft_rock"))),
|
|
("흙깎기", "보통암", value(quantities.get("cut_rock"))),
|
|
("옆도랑파기", "토사", value(quantities.get("ditch_soil"))),
|
|
("옆도랑파기", "연암", value(quantities.get("ditch_soft_rock"))),
|
|
("옆도랑파기", "보통암", value(quantities.get("ditch_rock"))),
|
|
("비탈보호공", "성토면", value(quantities.get("fill_slope_protection"))),
|
|
("비탈보호공", "절토면", value(quantities.get("cut_slope_protection"))),
|
|
("기타", "지장목제거", value(quantities.get("tree_removal"))),
|
|
("기타", "흙쌓기", value(quantities.get("embankment"))),
|
|
("기타", "제근", value(quantities.get("grubbing"))),
|
|
("기타", "노면고르기", value(quantities.get("surface_grading"))),
|
|
]
|
|
|
|
|
|
def _quantity_table_entities(
|
|
source: dict[str, Any], drawing_id: str, points: list[tuple[float, float]]
|
|
) -> list[dict[str, Any]]:
|
|
if not points:
|
|
return []
|
|
rows = [("구분", "항목", "값"), *_quantity_rows(source)]
|
|
row_height = 0.75
|
|
column_widths = (3.2, 3.2, 3.0)
|
|
left = max(point[0] for point in points) + 2.0
|
|
top = max(point[1] for point in points)
|
|
right = left + sum(column_widths)
|
|
bottom = top - row_height * len(rows)
|
|
entities: list[dict[str, Any]] = []
|
|
for row_index in range(len(rows) + 1):
|
|
y = top - row_index * row_height
|
|
entities.append(
|
|
_line_entity(
|
|
f"{drawing_id}:table-h",
|
|
row_index,
|
|
(left, y),
|
|
(right, y),
|
|
"b07-quantity-table",
|
|
"#64748b",
|
|
)
|
|
)
|
|
x_positions = [left]
|
|
for width in column_widths:
|
|
x_positions.append(x_positions[-1] + width)
|
|
for column_index, x in enumerate(x_positions):
|
|
entities.append(
|
|
_line_entity(
|
|
f"{drawing_id}:table-v",
|
|
column_index,
|
|
(x, top),
|
|
(x, bottom),
|
|
"b07-quantity-table",
|
|
"#64748b",
|
|
)
|
|
)
|
|
text_index = 0
|
|
for row_index, row in enumerate(rows):
|
|
y = top - row_index * row_height - 0.5
|
|
for column_index, label in enumerate(row):
|
|
entities.append(
|
|
_text_entity(
|
|
drawing_id,
|
|
text_index,
|
|
label,
|
|
(x_positions[column_index] + 0.12, y),
|
|
)
|
|
)
|
|
text_index += 1
|
|
return entities
|
|
|
|
|
|
def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]:
|
|
"""B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다."""
|
|
x_key = "chainage_m" if kind == "longitudinal" else "offset_m"
|
|
points: list[tuple[float, float]] = []
|
|
for sample in source.get("samples", []):
|
|
if not isinstance(sample, dict) or not sample.get("valid", False):
|
|
continue
|
|
x = sample.get(x_key)
|
|
y = sample.get("elevation_m", sample.get("z"))
|
|
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
|
points.append((float(x), float(y)))
|
|
children = [
|
|
_line_entity(drawing_id, index, points[index], points[index + 1])
|
|
for index in range(len(points) - 1)
|
|
]
|
|
entities: list[dict[str, Any]] = []
|
|
if children:
|
|
entities.append(
|
|
{
|
|
"id": str(
|
|
uuid5(
|
|
UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"),
|
|
drawing_id,
|
|
)
|
|
),
|
|
"type": "PolyLine",
|
|
"lineColor": "#f5f7fa",
|
|
"lineWidth": 1,
|
|
"layerId": _GROUND_LAYER_ID,
|
|
"shapeData": None,
|
|
"children": children,
|
|
}
|
|
)
|
|
if kind == "cross":
|
|
entities.extend(_quantity_table_entities(source, drawing_id, points))
|
|
return {
|
|
"entities": entities,
|
|
"layers": [
|
|
{
|
|
"id": _GROUND_LAYER_ID,
|
|
"name": "Existing Ground",
|
|
"isVisible": True,
|
|
"isLocked": False,
|
|
},
|
|
{
|
|
"id": "b07-quantity-table",
|
|
"name": "Quantity Table",
|
|
"isVisible": True,
|
|
"isLocked": False,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _read_drawing(
|
|
project_root: Path, longitudinal_path: Path, drawing_id: str
|
|
) -> tuple[str, str, dict[str, Any], bool]:
|
|
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
|
|
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
|
|
if manifest_entry.get("confirmed") and saved_path.is_file():
|
|
kind = "longitudinal" if drawing_id == "longitudinal" else "cross"
|
|
label = str(manifest_entry.get("label") or drawing_id)
|
|
return kind, label, _read_json(saved_path), True
|
|
if drawing_id == "longitudinal":
|
|
source = _read_json(longitudinal_path)
|
|
return (
|
|
"longitudinal",
|
|
"종단도 전체",
|
|
_cad_drawing(source, drawing_id, "longitudinal"),
|
|
False,
|
|
)
|
|
|
|
if not _CROSS_ID.fullmatch(drawing_id):
|
|
raise ValueError("올바르지 않은 도면 ID입니다.")
|
|
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
|
|
if not path.is_file():
|
|
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
|
|
source = _read_json(path)
|
|
label = str(source.get("label") or drawing_id)
|
|
return "cross", label, _cad_drawing(source, drawing_id, "cross"), False
|
|
|
|
|
|
def _store_confirmed_drawing(
|
|
project_root: Path,
|
|
item: DesignDrawingItem,
|
|
drawing: dict[str, Any],
|
|
expected_ids: set[str],
|
|
) -> bool:
|
|
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
|
|
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
|
|
drawings_dir = _design_root(project_root) / "drawings"
|
|
drawings_dir.mkdir(parents=True, exist_ok=True)
|
|
path = drawings_dir / f"{item.id}.json"
|
|
temporary = path.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
manifest = _read_manifest(project_root)
|
|
manifest["drawings"][item.id] = {
|
|
"kind": item.kind,
|
|
"label": item.label,
|
|
"confirmed": True,
|
|
"file": f"drawings/{item.id}.json",
|
|
}
|
|
_write_manifest(project_root, manifest)
|
|
confirmed_ids = {
|
|
item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed")
|
|
}
|
|
return expected_ids.issubset(confirmed_ids)
|
|
|
|
|
|
def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
|
|
manifest = _read_manifest(project_root)
|
|
entry = manifest["drawings"].get(drawing_id)
|
|
if entry:
|
|
entry["confirmed"] = False
|
|
_write_manifest(project_root, manifest)
|
|
|
|
|
|
@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)
|
|
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
|
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)
|
|
kind, label, drawing, confirmed = await asyncio.to_thread(
|
|
_read_drawing, project_root, longitudinal_path, drawing_id
|
|
)
|
|
return DesignDrawingResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
id=drawing_id,
|
|
kind=kind,
|
|
label=label,
|
|
drawing=drawing,
|
|
confirmed=confirmed,
|
|
)
|
|
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:
|
|
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다."""
|
|
try:
|
|
_, project_root, longitudinal_path = await _confirmed_source(project_id)
|
|
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
|
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
|
|
if not item:
|
|
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
|
|
all_confirmed = await asyncio.to_thread(
|
|
_store_confirmed_drawing,
|
|
project_root,
|
|
item,
|
|
request.drawing,
|
|
{candidate.id for candidate in items},
|
|
)
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
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,
|
|
)
|
|
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:
|
|
_, 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)
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
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": "상세 설계 도면 상태를 되돌리지 못했습니다."},
|
|
)
|