diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index ec23fcb53..85f66877f 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -29,6 +29,7 @@ `pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈 (짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122). `unlinked` = 셈한 칸에 사본 없는 시설 — 칸 전부 + 연결 여부(줄 「미연결」 · 바로 연결 · PLAN 154). +`facilities` = 같은 꼴로 모든 시설(모달 「조합 연결」 칸 · [연결 끊기] · PLAN 156-1). """ from __future__ import annotations @@ -605,6 +606,9 @@ def pipe_summary( notes.append(f"세월교 관 길이 없음 {no_ford}곳 — B06 횡단 [저장] 뒤 채워짐") if one_m is None: notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸") + names_of = {t["type_id"]: t["name"] for t in kinds.get("types") or []} + stored = {key: point.group_items or {} for key, point in raw.items()} + every = {(item["type_id"], item["복사본"].partition(GROUP_JOIN)[0]) for item in items} return { **out, "notes": notes, @@ -612,13 +616,10 @@ def pipe_summary( "types": list(DRAIN_TYPES), "pipes": pipes, "linked": linked, - "unlinked": unlinked_facilities( # 154-1 · 줄 누름 → 바로 연결(154-2) - items, - linked, - missing, - {t["type_id"]: t["name"] for t in kinds.get("types") or []}, - {key: point.group_items or {} for key, point in raw.items()}, - ), + # 154-1 · 줄 누름 → 바로 연결(154-2) + "unlinked": unlinked_facilities(items, linked, missing, names_of, stored), + # 모든 시설 칸(모달 「조합 연결」 · 연결된 칸 [연결 끊기] · 156-1) + "facilities": unlinked_facilities(items, linked, every, names_of, stored), } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index 43172e579..aadb301bc 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -62,6 +62,11 @@ padding-left: var(--spacing-16); } +.b08-link__buttons { + display: flex; + gap: var(--spacing-8); +} + .b08-link__state { color: var(--color-text-secondary); } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index 2e3167817..aa764528a 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -6,7 +6,7 @@ * * 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만 * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) - * 미연결 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2) — 저장하면 닫고 다시 셈 + * 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2 · 156-1) — 저장 · 끊기면 닫고 다시 셈 * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ @@ -44,9 +44,9 @@ export interface SheetsRequest { /** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */ linked: Record; describe: (item: SummaryItem) => ComboLabel | null; - /** 그 측점 미연결 시설(154) */ - unlinked: UnlinkedFacility[]; - /** 바로 연결을 저장한 뒤 — 집계표 다시 셈 */ + /** 그 측점 시설 칸 전부(154 · 156-1) */ + facilities: UnlinkedFacility[]; + /** 바로 연결 · 끊기를 저장한 뒤 — 집계표 다시 셈 */ onLinked: () => void; } @@ -120,9 +120,9 @@ export function openStructureSheets(request: SheetsRequest): void { return true; }, mount: (body, close) => { - const linking = request.unlinked.length + const linking = request.facilities.length ? [ - linkSection(request.projectId, request.unlinked, () => { + linkSection(request.projectId, request.facilities, () => { closed = true; handles.forEach((cards) => cards.destroy()); close(); diff --git a/B08_Quantity/B08_Quantity_UI_Link.ts b/B08_Quantity/B08_Quantity_UI_Link.ts index b7f9df6cb..2f8329d3e 100644 --- a/B08_Quantity/B08_Quantity_UI_Link.ts +++ b/B08_Quantity/B08_Quantity_UI_Link.ts @@ -1,14 +1,16 @@ /* ============================================================================= * B08_Quantity_UI_Link.ts - * 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2) — 그 측점 미연결 시설의 칸 전부 + 연결 여부. + * 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2 · 156) — 그 측점 시설의 칸 전부 + 연결 여부. * * [선택] → 공용 고르기 모달(`openItemPicker` · 누를 때 `import()`) → 바로 저장 * = PUT …/items/{사본 id}/link(그 칸 하나만 정본에 · 서버가 사본 맞춤) → 집계표 다시 셈 + * [연결 끊기](연결된 칸만) → 확인 창 → 같은 길에 null(그 칸만 비움 · 사본 폴더 그대로 · 156-1) + * 단추 = 모달 머리 [닫기]와 같은 양식(ghost · 기본 크기 · 156-3) * B05/B06 세션 초안(구조물 · 관)이 있으면 막고 알림 — 그쪽 [저장]이 목록 통째로 덮어 연결이 사라짐 * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, showToast } from "@ui/ui_template_elements"; +import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { projectItems } from "@ui/ui_template_item_picker_project"; import { readPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; @@ -54,6 +56,24 @@ async function pickAndLink( } } +/** 칸 하나 연결 끊기 — 끊었으면 true(취소 · 막힘 · 실패 = false · 156-1). */ +async function unlink(projectId: string, facility: UnlinkedFacility, slot: UnlinkedSlot) { + if (hasPendingDesign(projectId)) { + showToast(L("B08_Quantity_Link_Pending"), "warning", 5000); + return false; + } + if (!(await showConfirmDialog(L("B08_Quantity_Link_UnlinkAsk"), L("B08_Quantity_Link_Unlink")))) + return false; + try { + await projectItems(projectId).link(slot.copy, facility.facility, null); + showToast(L("B08_Quantity_Link_Unlinked"), "success"); + return true; + } catch (error) { + showToast(`${L("B08_Quantity_Link_UnlinkFailed")} — ${String(error)}`, "error"); + return false; + } +} + /** 「조합 연결」 칸 — 시설마다 이름 + 칸 줄(이름 · 연결 여부 · [선택]). `onLinked` = 저장 뒤(모달 닫고 다시 셈). */ export function linkSection( projectId: string, @@ -83,18 +103,26 @@ export function linkSection( state.textContent = slot.linked ? `${L("B08_Quantity_Link_Linked")} · ${slot.item?.name ?? ""}` : L("B08_Quantity_Summary_Unlinked"); - const button = createButton({ - label: L("B08_Quantity_Link_Pick"), - variant: "ghost", - size: "sm", - onClick: () => { - button.disabled = true; - void pickAndLink(projectId, facility, slot).then((saved) => - saved ? onLinked() : (button.disabled = false), - ); - }, - }); - row.append(name, state, button); + const buttons = document.createElement("span"); + buttons.className = "b08-link__buttons"; + const act = (label: string, run: typeof pickAndLink): HTMLButtonElement => { + const button = createButton({ + label, + variant: "ghost", // 모달 [닫기]와 같은 양식(156-3) + onClick: () => { + buttons.querySelectorAll("button").forEach((b) => (b.disabled = true)); + void run(projectId, facility, slot).then((saved) => + saved + ? onLinked() + : buttons.querySelectorAll("button").forEach((b) => (b.disabled = false)), + ); + }, + }); + return button; + }; + buttons.append(act(L("B08_Quantity_Link_Pick"), pickAndLink)); + if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink)); + row.append(name, state, buttons); section.append(row); } } diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index b32efc609..16098f2a7 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -81,6 +81,8 @@ interface PipeSummary { linked: Record; /** 미연결 시설(154-1) */ unlinked: UnlinkedFacility[]; + /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ + facilities: UnlinkedFacility[]; } async function fetchPipeSummary(projectId: string): Promise { @@ -247,7 +249,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin combos: combosAt(things, station), linked, describe, - unlinked: unlinkedAt(unlinked, station), + facilities: unlinkedAt(saved?.facilities ?? [], station), // 새 몸통에 다시 그림 — 옛 몸통은 떨어져 옵션 듣기가 스스로 빠짐(겹침 없음) onLinked: () => { const fresh = document.createElement("div"); diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 0a6188e6b..d2b37c796 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -9,7 +9,8 @@ POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) PUT …/items/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설 - 조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed} + 조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed} · null = 끊기(156-1 · + 맞춤 안 함 · 사본 폴더 그대로) POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 · 기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값) 종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) · @@ -164,6 +165,8 @@ async def link_item( 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) + if item is None: # 끊기(156-1) — 맞춤은 고아 사본을 지우므로 안 부름 · 사본 폴더 그대로 + return {"폴더": folder_name(copy), "snapshot_failed": []} failed = await asyncio.to_thread(safe_sync, root, importer(session)) return {"폴더": folder_name(copy), "snapshot_failed": failed} diff --git a/resources/tester/test_156_1_b08_unlink.py b/resources/tester/test_156_1_b08_unlink.py new file mode 100644 index 000000000..f67f91996 --- /dev/null +++ b/resources/tester/test_156_1_b08_unlink.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""B08 조합 [연결 끊기](PLAN 156-1 · 156-3) — 임시 저장소(154-2 시험 바탕 그대로). + +- 링크 길에 null = 그 시설 칸만 비움 · 다른 시설 · 칸 · 관 지점 노선 지문 그대로 · 구조물 판 +1 +- 끊기는 사본 맞춤을 안 부름 — 사본 폴더 그대로(고아 정리 안 함) +- 집계 응답 `facilities` = 모든 시설 칸(연결된 칸 포함) · 끊은 시설은 `unlinked` 로 +- 화면 = 연결된 칸만 [연결 끊기] · 확인 창 · 초안이면 막음 · 단추 = 모달 [닫기] 양식 +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Project_Items_Router as items_router +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition +from resources.tester.test_154_2_b08_direct_link import ( # noqa: F401 — 시험 저장소 + MS, + PIPE, + ROOT, + _points, + _structures, + root, +) + + +def _folders(root: Path) -> set[str]: + return {p.name for p in snap.structures_dir(root).iterdir() if p.is_dir()} + + +def _unlink(monkeypatch, root: Path, copy: str, facility: str | None) -> dict: + async def fake_root(session, project_id): + return root + + def no_sync(*args): + raise AssertionError("끊기는 사본 맞춤을 부르지 않음") + + monkeypatch.setattr(items_router, "_root", fake_root) + monkeypatch.setattr(items_router, "safe_sync", no_sync) + body = items_router.LinkBody(summary_item=None, facility=facility) + return asyncio.run(items_router.link_item("proj", copy, body, session={})) + + +def test_구조물_끊기_그_칸만_사본_그대로(root, monkeypatch): + folders, points = _folders(root), _points(root) + assert snap.folder_name("st1") in folders + reply = _unlink(monkeypatch, root, "st1", None) + assert reply == {"폴더": snap.folder_name("st1"), "snapshot_failed": []} + got = _structures(root) + assert got["revision"] == 5 + st0, st1 = got["structures"] + assert st1.get("summary_item") is None and st1["chainage_m"] == 400 + assert st0.get("summary_item") is None and st0["chainage_m"] == 300 + assert _points(root) == points # 관 지점 · 노선 지문 그대로 + assert _folders(root) == folders # 사본 폴더 그대로 + assert "st1" not in snap.linked_snapshots(root) + + +def test_관_그룹_끊기_다른_시설_그대로(root, monkeypatch): + folders, before = _folders(root), _points(root) + _unlink(monkeypatch, root, "pipe@100.0__grv6lny", "pipe") + after = _points(root) + assert after["route_signature"] == "sig-1" + assert "group_items" not in after["points"][0] + assert after["points"][1:] == before["points"][1:] # 같은 측점 박스 · 다른 관 그대로 + assert _structures(root)["structures"][1]["summary_item"] == MS + assert _folders(root) == folders + + +def test_집계_모든_시설_칸_끊으면_미연결(root, monkeypatch): + names = definition.read()["종류"]["pipe"]["이름"] + designs = [{"chainage_m": 100.0, "design": {"pipe_length_m": 12}}] + first = summary.pipe_summary(root, designs, names, 6.0) + assert not any(u["id"] == "st1" for u in first["unlinked"]) + st1 = next(f for f in first["facilities"] if f["id"] == "st1") + assert st1["slots"][0] == {"copy": "st1", "group": None, "item": MS, "linked": True} + pipe = next( + f for f in first["facilities"] if f["id"] == "pipe@100.0" and f["type_id"] == "pipe" + ) + assert any(s["copy"] == "pipe@100.0__grv6lny" and s["linked"] for s in pipe["slots"]) + assert {u["id"] for u in first["unlinked"]} <= {f["id"] for f in first["facilities"]} + _unlink(monkeypatch, root, "st1", None) + after = summary.pipe_summary(root, designs, names, 6.0) + st1 = next(u for u in after["unlinked"] if u["id"] == "st1") + assert st1["slots"][0]["linked"] is False and st1["chainage_m"] == 400 + assert PIPE == _points(root)["points"][0]["group_items"]["grv6lny"] # 다른 칸 그대로 + + +def test_화면_연결된_칸만_끊기_닫기_양식(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8") + assert 'if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink))' in ui + assert ".link(slot.copy, facility.facility, null)" in ui + assert 'showConfirmDialog(L("B08_Quantity_Link_UnlinkAsk")' in ui + assert ui.count("hasPendingDesign(projectId)") == 2 # 선택 · 끊기 둘 다 막음 + assert 'size: "sm"' not in ui # 모달 [닫기] = ghost · 기본 크기 + modal = (ROOT / "ui_template/ui_template_modal.ts").read_text(encoding="utf-8") + assert 'label: opts.closeLabel,\n variant: "ghost",\n onClick' in modal + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "facilities: unlinkedAt(saved?.facilities ?? [], station)" in page + sheets = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") + assert "linkSection(request.projectId, request.facilities" in sheets diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index f7e155491..2d52a5331 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -69,6 +69,13 @@ export const ui_locales_b4 = { "연결은 저장했으나 사본을 못 만듦", "Linked, but the project copy could not be made", ], + B08_Quantity_Link_Unlink: ["연결 끊기", "Unlink"], + B08_Quantity_Link_UnlinkAsk: [ + "이 칸의 조합 연결을 끊습니다 — 프로젝트 사본은 남습니다.", + "Unlink this slot — the project copy is kept.", + ], + B08_Quantity_Link_Unlinked: ["조합 연결을 끊었습니다.", "Combination unlinked."], + B08_Quantity_Link_UnlinkFailed: ["연결을 끊지 못했습니다", "Failed to unlink"], B08_Quantity_Link_Pending: [ "B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.", "Unsaved edits in B05 · B06 — save there first, then link.",