feat(B08): 미연결 구조물 바로 연결 — 고르기 모달 → 그 칸만 저장 → 다시 셈(154-2)

- 새 서버 길 PUT /api/projects/{id}/items/{사본 id}/link — 그 시설 조합 칸 하나만 정본에
  (관 지점 = pipe_points.json 노선 지문 그대로 · 박스/관 facility 로 가름 · 구조물 = 판번호 +1) → safe_sync
- 구조물도 모달 맨 위 「조합 연결」 칸(새 B08_Quantity_UI_Link) — 칸마다 연결 여부 · [선택]
- B05/B06 세션 초안(구조물 · 관) 있으면 막고 「B05 · B06 [저장] 먼저」 알림 · 저장 뒤 탭 몸통 새로 그림
- 공용 창구 projectItems().link 추가 · 새 시험 test_154_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-09 19:11:16 +09:00
co-authored by Claude Opus 5.5
parent 3c510d27f4
commit 266d003ce7
9 changed files with 457 additions and 19 deletions
+64 -2
View File
@@ -1,5 +1,5 @@
"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산
(PLAN 25-2b · 26-2b).
"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산 ·
바로 연결(PLAN 25-2b · 26-2b · 154-2).
복사본 = `{프로젝트}/templates/structures/{구조물 id}/`
(`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id.
@@ -15,7 +15,10 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in
from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import GROUP_JOIN
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -342,3 +345,62 @@ def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
read_json(target / PICKS_FILE) or {},
read_json(target / KINDS["면적"]),
)
def _put(record: dict[str, Any], gid: str, item: dict[str, Any] | None) -> None:
"""조합 칸 하나 — 본체 = `summary_item` · 그룹 = `group_items[gid]` · None = 연결 풀기."""
if not gid:
record["summary_item"] = item
return
groups = dict(record.get("group_items") or {})
if item is None:
groups.pop(gid, None)
else:
groups[gid] = item
record["group_items"] = groups or None
def link(
project_root: str | Path, copy: str, facility: str | None, item: dict[str, Any] | None
) -> None:
"""B08 바로 연결(154-2) — 그 시설 조합 칸 하나만 정본에 씀 · 다른 칸 · 시설 그대로.
`copy` = 사본 id(본체 = 시설 id · 그룹 = `{id}__{그룹}`) · 관 지점(`pipe@측점`)은 같은 측점
박스 + 관을 `facility` 로 가름 → `pipe_points.json`(노선 지문 그대로) · 구조물 →
`structures.json`(판번호 +1). 사본 맞춤(`safe_sync`)은 부른 쪽."""
owner, _, gid = copy.partition(GROUP_JOIN)
root = Path(project_root)
if owner.startswith("pipe@"):
path = pipe_points_path_in(root)
document = read_json(path)
points = document.get("points") if isinstance(document, dict) else None
at = owner.removeprefix("pipe@")
kind = facility or "pipe"
found = next(
(
point
for point in points or []
if isinstance(point, dict)
and f"{round(float(point.get('chainage_m', -1)), 3)}" == at
and (point.get("facility") or "pipe") == kind
),
None,
)
if found is None:
raise LookupError(f"관 지점이 없음 「{copy}」 ({kind})")
_put(found, gid, item)
for key in ("summary_item", "group_items"): # 저장 꼴(`PipePoint.as_dict`) — 빈 칸은 뺌
if not found.get(key):
found.pop(key, None)
atomic_write_json(path, document)
return
if not owner or owner.startswith("#"):
raise ValueError(f"id 없는 옛 구조물은 못 이음 「{copy}」 — B05 에서 한 번 [저장]")
revision, structures = load_structures(str(root))
target = next((one for one in structures if one.structure_id == owner), None)
if target is None:
raise LookupError(f"구조물이 없음 「{owner}」")
record = {"summary_item": target.summary_item, "group_items": target.group_items}
_put(record, gid, item)
target.summary_item, target.group_items = record["summary_item"], record["group_items"]
save_structures(str(root), structures, base_revision=revision)
+30 -1
View File
@@ -8,6 +8,8 @@
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
PUT …/items/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설
조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed}
POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 ·
기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값)
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
@@ -29,7 +31,7 @@ from common_util.common_util_summary_item import parse_summary_item
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Snapshot import importer
from M02_MasterTemplete.M02_Item_Snapshot import folder_name, importer, safe_sync
from M02_MasterTemplete.M02_Item_Source import item_name
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
@@ -59,6 +61,11 @@ class SaveAsBody(BaseModel):
이름: str | None = None
class LinkBody(BaseModel):
summary_item: dict[str, Any] | None = None
facility: str | None = None # 관 지점 종류(같은 측점 박스 + 관 가름) · 구조물은 없음
class NewBody(BaseModel):
structure: str
경로: list[str] = []
@@ -139,6 +146,28 @@ async def read_item_file(
return await _run(copies.read_file, await _root(session, project_id), folder, kind)
@router.put("/{project_id}/items/{copy}/link") # `{폴더}/{종류}` 길보다 먼저
async def link_item(
project_id: str, copy: str, body: LinkBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
item = None
if body.summary_item is not None:
raw = body.summary_item
try: # 모양 먼저(이름은 자리만) — 이름이 없으면 층 SS 줄에서
item = parse_summary_item({**raw, "name": raw.get("name") or "-"})
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if not raw.get("name"):
try:
item["name"] = item_name(item)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
root = await _root(session, project_id)
await _run(copies.link, root, copy, body.facility, item)
failed = await asyncio.to_thread(safe_sync, root, importer(session))
return {"폴더": folder_name(copy), "snapshot_failed": failed}
def _recalc(document: dict) -> dict:
try:
return spreadsheet.with_server_values(document)